Merge commit 'refs/codex-unblock/20260723/master' into worktree/pty-review-fixes
# Conflicts: # .agents/notes/implemented/feature/2026-06-30-interception-seams.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/tools.md # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/tools/README.md # packages/core/tools/src/index.ts # packages/core/tools/src/schema.ts # packages/core/tools/tests/tools.spec.ts # packages/pty/tool-pty/README.md # packages/pty/tool-pty/src/index.ts # packages/pty/tool-pty/src/render.ts # packages/tasks/tool-tasks/README.md # packages/tasks/tool-tasks/src/index.ts
This commit is contained in:
@@ -8,7 +8,7 @@ Tool parameters must reach the model as standard JSON Schema while giving tool a
|
||||
|
||||
## Decision
|
||||
|
||||
A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required: true` booleans), type-level `InferArgs<S>` mapping a spec to the argument type (required keys non-optional, others genuinely optional via `?`), a runtime `schemaSpecToJsonSchema()` converter, and `defineTool()` tying them together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` — that's how MCP-sourced tools arrive.
|
||||
This decision is superseded by the [unified JSON-value schema DSL](2026-07-20-unified-json-value-schema-dsl.md), which retains the small authoring surface while making parameters and typed values share one vocabulary. `ParameterSchemaSpec` keeps per-property `required: true`; `InferArgs<S>` maps required keys to non-optional properties; `parameterSchemaSpecToJsonSchema()` compiles the implicit open object root; and `defineTool()` ties inference, compilation, and validation together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` for MCP and other external tools.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -17,5 +17,5 @@ A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required
|
||||
## Consequences
|
||||
|
||||
- First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy).
|
||||
- The DSL is deliberately small (string/number/boolean/object/array, enum, default, nested properties/items). Gaps vs full JSON Schema (unions, formats, constraints) are accepted until real tools demand them.
|
||||
- The owning unified note defines the current nodes, literal constraints, unions, JSON-value boundary, and object-openness rules.
|
||||
- The `InferArgs` mapping is regression-tested at the type level after an early optionality bug.
|
||||
@@ -4,13 +4,13 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
`defineTool` ([the custom schema DSL](2026-06-11-custom-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs<S>` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, an enum value outside the set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape (a generic stack trace the model can't act on) or, worse, silently misbehaved. Meanwhile the converter already encodes the exact structure a validator would need to walk.
|
||||
`defineTool` ([the unified schema DSL](2026-07-20-unified-json-value-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs<S>` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, or a literal outside the declared set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape or silently misbehaved.
|
||||
|
||||
## Decision
|
||||
|
||||
`validateArgs(spec, args): string[]` interprets a `SchemaSpec` over a runtime value, returning human-readable violations (empty = valid), and is total (never throws). `defineTool` runs it before the typed body; on violations it throws `ToolArgsError` (`code: 'INVALID_ARGS'`, message listing the violations), which the registry's existing execute-waterfall catch turns into an `isError` result the model reads and self-corrects from.
|
||||
`validateArgs(spec, args): string[]` compiles a `ParameterSchemaSpec` and delegates to the shared `validateJsonSchemaValue()` walker, returning human-readable violations for a well-formed declaration. `defineTool` snapshots the compiled parameter schema at definition time and runs that validation before the typed body; violations throw `ToolArgsError` (`INVALID_ARGS`), which the registry returns as an error result the model can correct.
|
||||
|
||||
The validator mirrors `schemaSpecToJsonSchema` semantics exactly — same structure walked, same rules: top level must be a non-array object; required keys come only from `required: true`; extra keys are allowed (no `additionalProperties: false`); `default` is not applied; an `object`/`array` prop without `properties`/`items` only type-checks; `enum` is membership. Raw-registered (MCP) tools are not touched — they validate their own input.
|
||||
The validator and compiler therefore share exact semantics: the implicit parameter root is an open object; required keys come only from `required: true`; defaults remain annotations; explicit nested objects honor their declared openness; arrays recurse through `items`; scalar literal constraints are type-correct; and `oneOf` accepts exactly one matching branch. Raw-registered tools own their input validation.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ The root plugin registers the full suite by composing the per-tool registration
|
||||
|
||||
## Testing
|
||||
|
||||
Tests follow the package boundary, not only the user-visible tools: the service seam in `dsh-fs`; real filesystem behavior through the `ctx.fs` interface in `dsh-fs-local` (resolution, symlinks, streaming, binary/UTF-8 rejection, unconditional and version-guarded writes, literal-edit semantics, line-ending preservation, structured `FsError` codes); the consumer surface in `dsh-tool-fs` against the real local provider (mock only the model/clock, never the collaborator); and integration through `ctx.tools.execute()` with and without `dsh-fs-policy`, world-verified by reading files back from disk rather than trusting the returned `ContentBlock[]`. The observed-state/owner-derivation policy is tested in `dsh-fs-policy`, not here.
|
||||
Tests follow the package boundary, not only the user-visible tools: the service seam in `dsh-fs`; real filesystem behavior through the `ctx.fs` interface in `dsh-fs-local` (resolution, symlinks, streaming, binary/UTF-8 rejection, unconditional and version-guarded writes, literal-edit semantics, line-ending preservation, structured `FsError` codes); the consumer surface in `dsh-tool-fs` against the real local provider (mock only the model/clock, never the collaborator); and integration through `ctx.tools.execute()` with and without `dsh-fs-policy`, world-verified by reading files back from disk rather than trusting either the canonical value or rendered content. The observed-state/owner-derivation policy is tested in `dsh-fs-policy`, not here.
|
||||
|
||||
The defensive-pattern classes this repo has been bitten by are pinned directly:
|
||||
|
||||
|
||||
+4
-5
@@ -16,11 +16,10 @@ The coordinator retires each live session from its `session/disposed` notificati
|
||||
|
||||
### The hook interface (`PersistenceBackend<TornMarker>`)
|
||||
|
||||
Six methods (five required + an optional lifecycle hook) — the only seam between the coordinator and storage:
|
||||
Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage:
|
||||
|
||||
- `name` — backend label for the dispose-failure `AggregateError`.
|
||||
- `loadStored(id)` — read a stored prefix by id, scanning ANY storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Used by resume/load and, via `!== undefined`, the create-collision probe.
|
||||
- `loadLive(id, cwd)` — read a stored prefix SCOPED to `cwd`. **Deliberately distinct from `loadStored`**: HMR live-adoption must only adopt a persisted log at the SAME cwd as the live session; a same-id log at a different cwd is a collision, not a resume. Collapsing the two reintroduces a cross-cwd adoption bug. SQLite ignores `cwd`.
|
||||
- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Resume/load, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication.
|
||||
- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook).
|
||||
- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`).
|
||||
- `list()` — list all stored metadata.
|
||||
@@ -37,8 +36,8 @@ The shared `runPersistenceContract` (public-API contract) keeps running for ever
|
||||
## 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 folded away: there is no separate `materialize` hook (the materialize-write must commit atomically with the first event batch inside `appendBatch`), no separate create-collision probe (it is `loadStored(id) !== undefined`), and no coordinator pass-through for `list()` (listing needs none of the orchestration).
|
||||
- **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.
|
||||
|
||||
## Consequences
|
||||
|
||||
The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves uncommitted buffers, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle.
|
||||
The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves uncommitted buffers, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, and collision checks reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle.
|
||||
@@ -25,6 +25,8 @@ The literal types live in the [task data-structure catalog](../../../../docs/cor
|
||||
|
||||
`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.
|
||||
|
||||
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`.
|
||||
|
||||
The producer hooks define three responsibilities:
|
||||
|
||||
- `cancel(reason?)` synchronously requests termination, is idempotent, and must cause `done` to settle.
|
||||
|
||||
@@ -53,9 +53,9 @@ packages/
|
||||
|
||||
The package list had been enumerated in five places. The uniform depth-2 layout lets most of them be derived instead:
|
||||
|
||||
- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. Root `tsconfig.json` reuses that source map and carries the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.)
|
||||
- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. The aggregate configs (`tsconfig.host.json`, `tsconfig.client.json`) reuse that source map and carry the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.)
|
||||
- `scripts/publint-all.ts` derives its list by reading the hierarchy (`packages/<group>/<pkg>`), resolving the `TODO(package-inventory)`.
|
||||
- `tsconfig.build.json`'s project `references` stay an explicit list — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)).
|
||||
- The aggregates' project `references` stay explicit lists — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)).
|
||||
|
||||
### Guardrails added
|
||||
|
||||
|
||||
+6
-10
@@ -14,24 +14,20 @@ The obstacle is a seam boundary: `presentResult(args, result)` is a **pure funct
|
||||
|
||||
Add a **persisted, tool-private presentation channel** so a tool's `execute` can attach a result-time render payload that survives replay, and use it to carry the applied-hunk diff.
|
||||
|
||||
### 1. A `meta` channel on the tool result (core)
|
||||
### 1. A replayable presentation projection on canonical tool output (core)
|
||||
|
||||
`ToolDefinition.execute` may now return either its model-facing `ContentBlock[]` (unchanged, the common case) OR `{ content: ContentBlock[]; meta?: unknown }`:
|
||||
The original implementation let `execute` return `{ content, meta }`. The [canonical tool-output contract](2026-07-20-canonical-tool-output-contract.md) supersedes that authoring shape: every tool now returns one schema-declared JSON value, `output.render(args, value)` derives model-facing blocks, and optional `output.presentationMeta(args, value)` derives replayable UI data.
|
||||
|
||||
```ts ignore-check
|
||||
type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
|
||||
```
|
||||
`presentationMeta` is tool-owned `JsonValue` that the core persists without interpreting its fields. `Session.append` validates it with the rest of the event, and replay passes the stored payload back to `presentResult`; presentations therefore reproduce without I/O or recomputation. The canonical value itself remains execution-local and is not added to the session format.
|
||||
|
||||
`meta` is tool-owned `unknown` that the core persists without interpretation. `Session.append` rejects non-JSON values, and replay passes the stored payload back to `presentResult`; presentations therefore reproduce without I/O or recomputation. Runtime validation avoids adding a shared serializable-value dependency to the tools core.
|
||||
|
||||
This is the general shape ("a tool attaches durable result presentation"), not an fs-specific one — any tool can use it.
|
||||
This remains the general shape ("a tool projects durable result presentation"), not an fs-specific one—any tool can use it.
|
||||
|
||||
### 2. The tool computes the hunk; the backend returns before/after (fs)
|
||||
|
||||
Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**:
|
||||
|
||||
- `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam.
|
||||
- `dsh-tool-fs` stores contextual hunks in `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally.
|
||||
- `dsh-tool-fs` returns canonical before/after mutation facts and projects contextual hunks as `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally.
|
||||
|
||||
### 3. The bridge renders a `diff` result card
|
||||
|
||||
@@ -43,7 +39,7 @@ Per the [capability-seam split](2026-06-13-capability-seams.md), the storage bac
|
||||
|
||||
## Consequences
|
||||
|
||||
`tool/result` events may now carry a tool-private `meta` payload — part of the on-disk vocabulary, runtime-gated to JSON by `Session.append` — and any tool can attach durable result presentation without another core change. The diff card reproduces on session reload and snapshot replay for free: it is read back from the log, never recomputed. The costs: an overwrite holds both the prior and new text in memory to compute a UI-only hunk (`TODO(overwrite-diff-bound)`), and `dsh-tool-fs` carries a small, well-known runtime dependency.
|
||||
`tool/result` events carry a tool-private `meta` payload—part of the on-disk vocabulary, runtime-gated to JSON by `Session.append`—and any tool can project durable result presentation without another core change. The diff card reproduces on session reload and snapshot replay for free: it is read back from the log, never recomputed. The costs: an overwrite holds both the prior and new text in memory to compute a UI-only hunk (`TODO(overwrite-diff-bound)`), and `dsh-tool-fs` carries a small, well-known runtime dependency.
|
||||
|
||||
## Non-goals
|
||||
|
||||
|
||||
@@ -150,6 +150,6 @@ The formatter hook is deliberately small: a tool turns a `RetentionNotice` into
|
||||
|
||||
**Put `read` windowing behind `ItemRetainer`.** Rejected for v1: `read` is the only current window consumer, and its semantics are file pagination rather than generic retention. A single `Omitted` count cannot represent both sides of a line window, and `read` also carries `totalLines`, offset-range errors, per-line preview truncation, and a byte cap over selected output. Keeping `read-render` tool-owned avoids growing the shared library around one special case.
|
||||
|
||||
**Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used before a tool returns `ContentBlock[]`; the model-facing result remains tool-owned.
|
||||
**Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used by a tool's Native renderer; the model-facing projection remains tool-owned while the [canonical value](2026-07-20-canonical-tool-output-contract.md) may retain the complete acquired result.
|
||||
|
||||
**Expose limits in every model-facing tool schema.** Rejected as the default: Claude Code's grep exposes `head_limit` / `offset`, but this harness keeps routine budgets as deployment config unless the model genuinely needs pagination control. A future read-like continuation field can be added per tool; it does not belong in the shared retention primitive.
|
||||
@@ -61,7 +61,10 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
|
||||
isError: true,
|
||||
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
|
||||
error: {
|
||||
message: `tool call timed out after ${timeoutMs}ms`,
|
||||
info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -8,7 +8,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 normally returns the text result, and a policy plugin enforces the deployment's default context budget. Tool-specific early spill remains possible later for outputs that do not survive to the final `ToolExecutionResult`; the first cut proves the default final-result path.
|
||||
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.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -97,9 +97,13 @@ The policy skips `read` to avoid a circular `read -> spill file -> read again` l
|
||||
```ts ignore-check
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'web_fetch',
|
||||
output: {
|
||||
schema: WEB_FETCH_RESULT_SCHEMA,
|
||||
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined)
|
||||
return [{ type: 'text', text: formatFetchOutput(result) }]
|
||||
return result
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
+2
-2
@@ -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
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: ebe21a6060ec69ba9807ab9fbf9906ae24b07823
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: 0c256b60ce44a8e16ec6edfba146c776c4ae2129
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: 65fb01f44698c61e6bf6958e332e1854fbb77fa9
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: e8b15789846ea124fb6a90f2afef437184d4348a
|
||||
@@ -53,7 +53,7 @@ Direction discipline (every rule auditable from package deps):
|
||||
- `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency.
|
||||
- Cross-package client imports use the `/client` subpath for plugin packages (a bare package name would inline a second runtime instance into a browser bundle; the tsdown purity gate rewrites or rejects it).
|
||||
|
||||
TypeScript checks in **two aggregate programs** (`tsconfig.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.
|
||||
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).
|
||||
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ harness core packages ──────────────────┘
|
||||
- `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。
|
||||
- client 侧跨包 import 插件包一律走 `/client` 子路径(裸包名会把第二份运行时实例内联进浏览器 bundle;tsdown 纯度门禁会改写或拒收)。
|
||||
|
||||
TypeScript 以**两个聚合 program** 检查(`tsconfig.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用。
|
||||
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` 传输切面)。
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
2026-07-19-gui-web-client-architecture.md: 58320570f752d4259004172d3b4527172c2cc646
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 744fdaa4b89a01e2710f85b177228713189e3025
|
||||
2026-07-19-gui-web-client-architecture.md: 6e1cbc2d1e3e3437480c8005ca06845c23c628df
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 9e2b3ef60d97840cd6cbd26e8fdcf922d472391c
|
||||
@@ -39,34 +39,19 @@ The loading chain, end to end:
|
||||
|
||||
**The dual-instance ban**: a module-table package inlined into a plugin bundle would duplicate runtime identity (two React copies, two store registries — the root cause of an actual white-screen P0). The tsdown client preset enforces purity at build time: a bare-name import of a module-table package must resolve external (rewritten to its `/client` form where applicable), and any other workspace leak that is not an inline-safe wire/type layer fails the build (`packages/client/tsdown.client.ts`, pinned by `scripts/client-bundle-purity.spec.ts`).
|
||||
|
||||
Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the same chain; vite serves only the shell (`apps/web`). Type universes stay split at the aggregate level — the root `tsconfig.json` is the host program, `tsconfig.client.json` the client program, 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.
|
||||
Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the same chain; vite serves only the shell (`apps/web`). 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.
|
||||
|
||||
## The slot system: how the page composes
|
||||
|
||||
A page is a tree of slots; whoever owns a region declares its slots. Contracts live in one place — the `SlotMap` interface in `@deepseek-ai/dsh-client-ui-slots`, extended by declaration merging. An entry declares the slot's axes and the **owner share** only; the registrant's injected props never enter the global table ("whoever injects it, owns its type"):
|
||||
The slot system has its own RFC — the [slot system standard](2026-07-22-slot-type-chain-implementation.md) — and this document defers to it entirely. The one-paragraph summary for orientation: the shell renders only `'root'`; a plugin composes UI through a single `register` call that occupies a slot, declares+authorizes its child slots (`children` spec object), declares its store, and injects its business face; component props arrive in four auto-derived shares (`PropsRuntime<K>` / `PropsRenderSlots<S>` / `PropsStore<H>` / inject), each from its single source of truth. `SlotMap` declaration merging is the type authority and entries carry only the owner share ("whoever injects it, owns its type"); every rendered entry sits in a per-entry error boundary.
|
||||
|
||||
```ts ignore-check
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap {
|
||||
sidebar: { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
|
||||
conversation: { kind: 'single'; scope: 'session'; owner: ConvOwnerProps; children: 'conversation.empty' }
|
||||
} }
|
||||
ctx.slots.define('sidebar', { kind: 'single', scope: 'root' }) // declare=类型,define=落账
|
||||
ctx.slots.register('sidebar', SidebarRoot, { inject: (b) => ({ /* ... */ }) })
|
||||
```
|
||||
|
||||
- Three kinds: `single` (duplicate registration throws), `list` (id/order), `keyed` (runtime dispatch, duplicate key throws). Register before define throws. Two scopes: `root` (no session context) and `session` — the scope decides the injection shape below.
|
||||
- **Full component props are composed by reference, never re-typed**: a registrant's component declares `OwnerOf<K> & StandardOf<K> & OwnInjected` — the owner share referenced from the slot owner's package, the standard share supplied by the framework (session slots: `useSession`), and the registrant's own injected share declared locally next to the component. `register<K, I>` enforces the composition at the call site: the component parameter is `SlotComponent<ComposedProps<K, NoInfer<I>>>` (a bare call signature, not `FC` — FC's `propTypes` static position generates contravariance noise against the standard share), and `I` is inferred exclusively from the inject factory's return type (`NoInfer` pins it), so a drifted component or a mismatched factory is a compile error at the registration point. In ui-conversation the injected shares live in `src/client/contract/slots.ts` (`ConversationInjected` and kin) and each skeleton component's props is a one-line reference composition.
|
||||
- **Delegation is a hand-written whitelist with an optional declared ceiling**: an owner component receives a whitelist-narrowed `slots: ScopedSlots<'a' | 'b'>` through its own props and calls `slots.renderSlot(key, props)`; passing a narrowed subset to a child goes through `narrowSlots` (pure type covariance). Overreach is a compile error, and the runtime whitelist backstops plain-JS callers. An entry may additionally declare `children: <key>` — register then validates the component's whitelist ⊆ the declared ceiling (opt-in visibility layer, not mandatory). Every rendered entry is wrapped in a per-entry error boundary: a crashing registrant (component or inject factory) blacks out only its own entry, while assembly errors (missing providers) rethrow — a miswired shell fails loud instead of degrading.
|
||||
- **Props merge from three sources** (the outlet does it; owners write only the first): ① owner-supplied props (identity, display parameters, frozen slices) — typed as the entry's owner share, exact at the renderSlot point; ② scope-standard injection — session slots automatically receive `useSession` bound to the right Session; ③ the registrant's `inject` factory, called once per (entry × session) for session slots and once per entry for root slots, cached in WeakMaps so a session switch-back reuses the cached result. Inject factories receive the assembly handle (`SessionBinding { sessionId, session, ctx }` or `RootBinding { ctx }`) — an apply-world object that never enters React.
|
||||
- Two supply channels close the loop: `RootBindingProvider` (mounted once by the shell) feeds root-slot inject factories their ctx; `createSessionProvider(deps)` builds the single session provider — dependency-inverted (`useCurrent` / `resolveBinding` / `renderBody`), so web-react never imports the runtime. It subscribes to the current session id, resolves a reference-stable binding, remounts its body under `key={id}`, and delegates body rendering to the assembler's `renderBody` closure (slot ownership stays with layout; the provider knows no slot names).
|
||||
|
||||
Implementation homes: registry core in `packages/client/ui-slots` (zero dependencies), outlet/providers/uSES bridge in `packages/client/web-react`.
|
||||
Implementation homes: registry core and the props-share types in `packages/client/ui-slots`, outlet/renderer/uSES bridge in `packages/client/web-react`.
|
||||
|
||||
## 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-map merges). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`), `ctx.sessions` (list store, scope tree, bindings), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (navigation + panel viewing state), `ctx.conversation` (send/cancel/selection/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters).
|
||||
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 install seam), `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).
|
||||
|
||||
Beyond SlotMap, two more typed registration rings follow the same declare-merge idiom: the **view ring** (`ConversationViewMap` — an entry may declare `chromeProps`/`extraProps` extension shapes; `ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` compose base + extension, so a view with no declaration gets the base for free while ui-trajectory's entries carry real per-view props) and the **tool ring** (tool names stay an open set — no global key table; typing hardens inside the entry: `ToolViewProps.block` is the real `ToolCallBlock` union defined in runtime, and register infers the registrant's injected share like slots do).
|
||||
There is no 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. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Session-dimension differentiation happens inside the component — `useSessions` reading `parentId` — not in registry predicates; interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early.
|
||||
|
||||
**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
|
||||
|
||||
@@ -101,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
|
||||
|
||||
The glue package is the whole ctx↔React boundary; components stay framework-free.
|
||||
|
||||
- `createSnapshotStore<T>(init, opts)`: the store engine for plugin-owned data and shell viewing state — zustand vanilla with draft-based updates, `flush: 'sync'` by default (controlled inputs need same-tick echo) with opt-in `'raf'` batching for frame-driven stores, opt-in whole-value localStorage persistence, dev-mode deep freeze. Both a Session object and a snapshot store satisfy the one data contract React consumes: `ObservableSnapshot<T>` (`getSnapshot`/`subscribe`).
|
||||
- The snapshot store engine **lives in the runtime package** (zustand vanilla with draft-based updates, `flush: 'sync'` by default with opt-in `'raf'` batching, opt-in whole-value localStorage persistence, dev-mode deep freeze — all exported from `runtime`'s `./client` main entry, no subpath): store products are bare observable sources with no hook members. Plugins reach the engine only through `defineStore` declarations per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). web-react composes every hook at the binding site (`bindSnapshotSelector`, per-source cached) from the one data contract React consumes: `ObservableSnapshot<T>` (`getSnapshot`/`subscribe`) — a Session object and a snapshot store both satisfy it. Business plugin packages depend on runtime and ui-slots only; web-react is shell-only glue.
|
||||
- `bindSnapshotSelector(source)`: binds a source into a typed selector hook over uSES-with-selector. The four uSES contract clauses hold by construction: getSnapshot returns the cached reference; subscribe is a bind-time closure (reference-stable forever); pure CSR passes no server snapshot; equality defaults to `Object.is` with `shallowEqual` opt-in per call.
|
||||
- `useInvoke(fn)`: wraps an async action into a stable trigger plus pending flag; pending rides a per-hook external store read through uSES (no setState on the render path), concurrent invocations are counted, and the invoke reference never changes.
|
||||
- Equality protocol, whole chain: producers use structural sharing; consumers short-circuit with `Object.is` or `shallowEqual`; `React.memo` shallow. Deep comparison is banned everywhere.
|
||||
@@ -118,19 +103,19 @@ src/client/
|
||||
service.ts cross-domain orchestration (imports contract only)
|
||||
skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
|
||||
chat/ domain: the chat view
|
||||
toolviews/ domain: the tool-row registry and samples
|
||||
toolviews/ domain: sample tool-row registrants (third-party posture)
|
||||
apply.ts the ONLY file allowed to import across domains (assembly point)
|
||||
index.ts thin re-export shell (contract + apply + components)
|
||||
```
|
||||
|
||||
Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. chat consumes the tool registry through a `ToolViewResolver` read-face interface, not the registry class). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths.
|
||||
Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. the toolviews samples take `ToolRowProps` from the contract, never chat internals). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths.
|
||||
|
||||
## 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, registers slots and toolviews), 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**: merge the contract into `SlotMap`, `define` at the owner, render through the owner's own `ScopedSlots` whitelist; registrants `register` with an optional inject factory. Never export components globally.
|
||||
- **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 slot**: see the [slot system standard RFC](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**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept.
|
||||
- **Where does this state live**: per-session and must survive switches → the Session object / scope-mounted store; private to one view (selection, scroll) → component state; shell viewing state (navigation, panel widths, preferences) → `ctx.layout`'s stores; business data → always the object layer, never a viewing-state store.
|
||||
- **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)).
|
||||
- **Notification channel**: frame-driven/async = `markDirty` batching; direct user-gesture echo whose controlled input needs the same tick = `notifyNow`.
|
||||
|
||||
## Consequences
|
||||
@@ -144,5 +129,5 @@ Token streams no longer shake the render tree: a frame storm costs unsubscribed
|
||||
| One statically-linked SPA bundle | Plugins must be host-composable at runtime (config-driven); a monolith re-couples every UI feature to one build |
|
||||
| window globals / import maps for shared deps | The DI require table keeps sharing explicit, fail-loud, and swappable; globals leak identity and version silently |
|
||||
| Business data in zustand slices | The event window/accumulator is a behavioral state machine, not a flat slice; the object layer keeps snapshot granularity and batching controllable |
|
||||
| String-keyed global component registry for tool rows | Tool views are consumed by multiple views and need per-session differentiation — a named service (`ctx.toolviews`) with scope filters is the honest shape |
|
||||
| String-keyed global component registry for tool rows | Per-view keyed child slots plus in-component session branching carry the same need with the one registration model; a parallel registry does not come back ([toolview dissolution](2026-07-23-toolview-dissolution.md)) |
|
||||
| Progressive/Suspense boot in P-I | One-flip boot is strictly simpler; the loader's per-plugin status face is kept so progressive lighting can land later without re-architecture |
|
||||
+12
-27
@@ -39,34 +39,19 @@ Status: implemented
|
||||
|
||||
**双实例禁令**:模块表包若被内联进插件 bundle,会复制运行时身份(两份 React、两套 store 注册表——一次真实白屏 P0 的根因)。tsdown client 预设在构建期把守纯度:模块表包的裸名 import 必须解析为 external(适用时改写为其 `/client` 形态),其余任何非 inline 安全 wire/类型层的 workspace 泄漏都令构建大声失败(`packages/client/tsdown.client.ts`,由 `scripts/client-bundle-purity.spec.ts` 钉住)。
|
||||
|
||||
dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走同一条链;vite 只管壳(`apps/web`)。类型宇宙在聚合层拆分——根 `tsconfig.json` 是 host program,`tsconfig.client.json` 是 client program,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。
|
||||
dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走同一条链;vite 只管壳(`apps/web`)。类型宇宙在聚合层拆分——`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。
|
||||
|
||||
## slot 体系:页面怎么拼
|
||||
|
||||
页面是一棵坑位树;谁拥有区域谁声明坑位。契约只有一个家——`@deepseek-ai/dsh-client-ui-slots` 的 `SlotMap` 接口,经声明合并扩展。entry 只声明坑的轴与 **owner 份额**;注册方的注入 props 永不进全局表(「谁注入的放谁那里」):
|
||||
slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-implementation.md)——本文整体移交给它。此处只留一段定位摘要:壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——占坑、声明并授权子坑(`children` spec 对象)、声明 store、注入业务面;组件 props 分四份额自动推导到达(`PropsRuntime<K>` / `PropsRenderSlots<S>` / `PropsStore<H>` / inject),各有唯一真源。`SlotMap` 声明合并仍是类型权威,entry 只携带 owner 份额(「谁注入的,类型归谁」);每个被渲染的注册项都在 per-entry 错误边界之内。
|
||||
|
||||
```ts ignore-check
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap {
|
||||
sidebar: { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
|
||||
conversation: { kind: 'single'; scope: 'session'; owner: ConvOwnerProps; children: 'conversation.empty' }
|
||||
} }
|
||||
ctx.slots.define('sidebar', { kind: 'single', scope: 'root' }) // declare=类型,define=落账
|
||||
ctx.slots.register('sidebar', SidebarRoot, { inject: (b) => ({ /* ... */ }) })
|
||||
```
|
||||
|
||||
- 三型:`single`(重复注册即 throw)、`list`(id/order)、`keyed`(运行时按 key 分发,重 key 即 throw)。define 之前 register 即 throw。两 scope:`root`(无会话语境)与 `session`——scope 决定下述注入形态。
|
||||
- **组件全量 props 一律引用组合,不重抄**:注册方组件声明 `OwnerOf<K> & StandardOf<K> & OwnInjected`——owner 份额从坑位 owner 的包引用、标配份额由框架供给(session 坑:`useSession`)、注册方自己的注入份额就地声明在组件旁。`register<K, I>` 在调用点强制组合:组件形参位是 `SlotComponent<ComposedProps<K, NoInfer<I>>>`(裸调用签名而非 `FC`——FC 的 `propTypes` 静态位对标配份额产生反变噪音),`I` 只从 inject 工厂返回值推断(`NoInfer` 钉死),组件漂移或工厂不匹配都在注册点编译报错。ui-conversation 的注入份额住 `src/client/contract/slots.ts`(`ConversationInjected` 族),各骨架组件的 props 是一行引用组合。
|
||||
- **转授=手写白名单+可选声明上限**:owner 组件经自己的 props 拿到白名单收窄的 `slots: ScopedSlots<'a' | 'b'>`,调 `slots.renderSlot(key, props)` 渲染;把收窄子集递给子组件走 `narrowSlots`(纯类型协变)。越权是编译错误,运行时白名单再兜住纯 JS 调用方。entry 可另声明 `children: <key>`——register 校验组件白名单 ⊆ 声明上限(可选可见层,不强制)。每个被渲染的注册项都包在 per-entry 错误边界里:注册方崩溃(组件或 inject 工厂)只黑自己那一格,装配错误(缺 provider)则重抛——接错线的壳大声失败而不是静默降级。
|
||||
- **props 三源合并**(出口组件来做;owner 只写第一份):① owner 供参(身份、展示参数、冻结切片)——按 entry 的 owner 份额强类型,renderSlot 点即精确;② scope 标配注入——session 坑自动获得绑定正确 Session 的 `useSession`;③ 注册方的 `inject` 工厂,session 坑 per-(注册项 × 会话) 调一次、root 坑 per-注册项调一次,以 WeakMap 缓存——切回会话时复用缓存结果。inject 工厂收到装配句柄(`SessionBinding { sessionId, session, ctx }` 或 `RootBinding { ctx }`)——apply 世界的对象,永不进入 React。
|
||||
- 两条供给通道收拢闭环:`RootBindingProvider`(壳顶部挂一次)为 root 坑 inject 工厂供给 ctx;`createSessionProvider(deps)` 构造唯一的会话 provider——依赖倒置(`useCurrent` / `resolveBinding` / `renderBody`),web-react 永不 import runtime。它订阅当前会话 id、解析引用恒等的 binding、以 `key={id}` 重挂其 body,并把 body 渲染委托给装配方的 `renderBody` 闭包(坑位所有权留在 layout;provider 不认识坑名)。
|
||||
|
||||
实现的家:注册表纯核在 `packages/client/ui-slots`(零依赖),出口组件/provider/uSES 桥在 `packages/client/web-react`。
|
||||
实现的家:注册表核心与 props 份额类型在 `packages/client/ui-slots`,出口组件/渲染器/uSES 桥在 `packages/client/web-react`。
|
||||
|
||||
## 服务与 scope 寻址
|
||||
|
||||
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`)、`ctx.sessions`(列表 store、scope 树、binding)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(导航 + 面板观看态)、`ctx.conversation`(send/cancel/selection/views/startSession)、`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。
|
||||
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
|
||||
|
||||
SlotMap 之外还有两条同 declare-merge 惯例的类型化注册环:**视图环**(`ConversationViewMap`——entry 可声明 `chromeProps`/`extraProps` 扩展形状;`ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` 组合基座+扩展,无声明的视图免费得基座,ui-trajectory 的两个 entry 带真 per-view props)与**工具环**(tool 名保持开放集——无全局键表;类型强化在 entry 内部:`ToolViewProps.block` 是 runtime 定义的真 `ToolCallBlock` union,register 同 slots 一样推断注册方注入份额)。
|
||||
slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝(conversation 服务在场即保证槽已声明)。会话维差异化在组件内完成——`useSessions` 读 `parentId`——不走注册表谓词;交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。
|
||||
|
||||
**scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
|
||||
|
||||
@@ -101,7 +86,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
|
||||
|
||||
胶水包就是整条 ctx↔React 边界;组件保持零框架依赖。
|
||||
|
||||
- `createSnapshotStore<T>(init, opts)`:插件自有数据与壳观看态的 store 引擎——zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`(受控输入要求同 tick 回响),帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结。Session 对象与快照 store 同构满足 React 消费的唯一数据契约:`ObservableSnapshot<T>`(`getSnapshot`/`subscribe`)。
|
||||
- 快照 store 引擎**住 runtime 包**(zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`,帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结——全部从 `runtime` 的 `./client` 主出口导出,无子路径):store 产物是裸的可观察源,不带任何 hook 成员。插件只经 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 的 `defineStore` 声明触及引擎。web-react 在绑定处(`bindSnapshotSelector`,按源缓存)从 React 消费的唯一数据契约合成每个 hook:`ObservableSnapshot<T>`(`getSnapshot`/`subscribe`)——Session 对象与快照 store 同构满足它。业务插件包只依赖 runtime 与 ui-slots;web-react 是仅壳可用的胶水。
|
||||
- `bindSnapshotSelector(source)`:把一个源绑定为经 uSES-with-selector 的带类型 selector hook。uSES 契约四条按构造成立:getSnapshot 恒返缓存引用;subscribe 是绑定期闭包(引用永稳);纯 CSR 不传 server snapshot;相等性缺省 `Object.is`,按调用可选 `shallowEqual`。
|
||||
- `useInvoke(fn)`:把异步动作包成引用恒定的触发器加 pending 标志;pending 走 per-hook 外部 store 经 uSES 读出(渲染路径零 setState),并发调用计数,invoke 引用永不变。
|
||||
- 相等性协议,全链一致:生产端结构共享;消费端以 `Object.is` 或 `shallowEqual` 短路;`React.memo` 浅比较。深比较全链禁止。
|
||||
@@ -118,19 +103,19 @@ src/client/
|
||||
service.ts cross-domain orchestration (imports contract only)
|
||||
skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
|
||||
chat/ domain: the chat view
|
||||
toolviews/ domain: the tool-row registry and samples
|
||||
toolviews/ domain: sample tool-row registrants (third-party posture)
|
||||
apply.ts the ONLY file allowed to import across domains (assembly point)
|
||||
index.ts thin re-export shell (contract + apply + components)
|
||||
```
|
||||
|
||||
域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 chat 经 `ToolViewResolver` 读面接口消费工具注册表,不碰注册表类)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。
|
||||
域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 toolviews 样例从契约取 `ToolRowProps`,永不碰 chat 内部)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。
|
||||
|
||||
## 怎么开发
|
||||
|
||||
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot 与 toolview),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。
|
||||
- **新 slot**:契约合并进 `SlotMap`,owner 处 `define`,经 owner 自己的 `ScopedSlots` 白名单渲染;注册方 `register`,按需带 inject 工厂。永不全局导出组件。
|
||||
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。
|
||||
- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——契约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。
|
||||
- **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。
|
||||
- **状态住哪**:per-session 且要跨切换存续 → Session 对象 / scope 挂账 store;单视图私有(选中、滚动)→ 组件状态;壳观看态(导航、面板宽、偏好)→ `ctx.layout` 的 store;业务数据 → 永远对象层,永不进观看态 store。
|
||||
- **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。
|
||||
- **通知通道**:帧驱动/异步 = `markDirty` 合批;受控输入需要同 tick 的用户手势直接回响 = `notifyNow`。
|
||||
|
||||
## Consequences
|
||||
@@ -144,5 +129,5 @@ token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位
|
||||
| 静态链接的单 SPA bundle | 插件必须由 host 在运行时按配置组合;单体把每个 UI 功能重新耦回一次构建 |
|
||||
| window 全局变量 / import map 供共享依赖 | DI require 表让共享显式、大声失败、可替换;全局变量静默泄漏身份与版本 |
|
||||
| 业务数据进 zustand 切片 | 事件窗口/累积器是行为状态机,不是扁平切片;对象层保住快照粒度与合批的可控性 |
|
||||
| 工具行走字符串键的全局组件注册表 | 工具视图被多个视图共同消费且要按会话差异化——带 scope 过滤的具名服务(`ctx.toolviews`)才是诚实形态 |
|
||||
| 工具行走字符串键的全局组件注册表 | per-view keyed 子槽 + 组件内会话分支以唯一注册模型承载同一需求;平行 registry 不复活([toolview 溶解](2026-07-23-toolview-dissolution.md)) |
|
||||
| P-I 就做渐进/Suspense 启动 | 一次成型严格更简单;loader 的按插件状态面已保留,渐进点亮日后可落地而无需重构 |
|
||||
+2
-2
@@ -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
|
||||
2026-07-19-zstandard-jsonl-session-logs.md: ccfc81dd47504e6a9e9b19cda7c4b9fc40accecc
|
||||
2026-07-19-zstandard-jsonl-session-logs.zh.md: de5436a6eaefcb45e52e0ff4fea8592c7efcd127
|
||||
2026-07-19-zstandard-jsonl-session-logs.md: 74430624c771a265fb281e588e28733bc55d3eb6
|
||||
2026-07-19-zstandard-jsonl-session-logs.zh.md: b22275d1a7c54a743b11f4396318dd87e4f5b42a
|
||||
@@ -36,7 +36,7 @@ EOF inside the final frame is a recoverable torn tail. Node's decoder is given t
|
||||
|
||||
### Consumers and verification
|
||||
|
||||
The CLI, ACP, and stdio app bundles expose symmetric `persistenceCompression` pass-through configuration. Snapshot recording and replay compositions select `'none'` explicitly because committed fixtures are raw JSONL inputs to replay and normalization; ordinary runtime compositions use the compressed default.
|
||||
The CLI, ACP, and stdio app bundles expose symmetric `persistenceCompression` pass-through configuration. The web host assembly and ordinary app compositions omit the option and use the compressed default. Snapshot recording and replay compositions select `'none'` explicitly because committed fixtures are raw JSONL inputs to replay and normalization.
|
||||
|
||||
The shared persistence and coordinator contracts run against both encodings. Backend tests cover standard framing and checksum interoperability, header-only listing, append rollback, encoding mismatch rejection, complete-frame corruption, and final-frame tears through headers, blocks, and checksum trailers. Default runtime, built-bin, headless, ACP, and Python smokes assert the compressed suffix and Zstandard magic or decode the header; raw-content tests opt out explicitly.
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量
|
||||
|
||||
### 消费方与验证
|
||||
|
||||
CLI、ACP 与 stdio 应用包公开对称的 `persistenceCompression` 透传配置。快照录制与回放组合显式选择 `'none'`,因为提交的 fixture 是回放与规范化过程使用的原始 JSONL 输入;普通运行时组合使用压缩默认值。
|
||||
CLI、ACP 与 stdio 应用包公开对称的 `persistenceCompression` 透传配置。web 宿主装配与普通应用组合省略该选项并使用压缩默认值。快照录制与回放组合显式选择 `'none'`,因为提交的 fixture 是回放与规范化过程使用的原始 JSONL 输入。
|
||||
|
||||
共享持久化契约与协调器契约会针对两种编码运行。后端测试覆盖标准帧与校验和互操作性、仅头部列举、追加回滚、编码不匹配拒绝、完整帧损坏,以及横跨头部、块和校验和尾部的最终帧撕裂。默认运行时、构建后二进制、headless、ACP 与 Python 冒烟测试会断言压缩后缀与 Zstandard 魔数,或解码头部;读取原始内容的测试则显式退出压缩。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-canonical-tool-output-contract.md: 8cd7df98758d6d4ac240fea21e7bbe0c89f26d1b
|
||||
2026-07-20-canonical-tool-output-contract.zh.md: 0920e0c2c331a247ecd9a039a05c19c9dd2871bc
|
||||
@@ -0,0 +1,79 @@
|
||||
# Agent Note: Canonical tool output contract
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-canonical-tool-output-contract.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Tool bodies previously authored model-facing `ContentBlock[]` directly, optionally wrapping it with opaque `meta`. Native function calling therefore had a usable human projection, but a programmatic caller had no stable domain value: Code Mode flattened the blocks back into a string, dynamic tools repeated the content shape, and policy could replace presentation without any way to distinguish that change from replacing the operation's result. Several capability seams already returned richer provider values only to discard them at their model-facing tool boundary.
|
||||
|
||||
The durable session contract made that presentation authoritative for replay, but persisting every rich intermediate value would enlarge logs, expose implementation data to compaction and migration, and incorrectly turn an execution-local API into session format. The foundation instead needs one typed value during execution and an explicit projection into the existing durable/model-facing content.
|
||||
|
||||
## Decision
|
||||
|
||||
Every tool declares a mandatory canonical output and returns only the value described by it:
|
||||
|
||||
```ts ignore-check
|
||||
output: {
|
||||
schema: OutputSchema
|
||||
render(args, value): ContentBlock[]
|
||||
presentationMeta?(args, value): JsonValue
|
||||
}
|
||||
```
|
||||
|
||||
`defineTool` infers the body return and both projectors from the unified `ValueSchemaSpec`. Raw and dynamic definitions provide the compiled `JsonSchemaNode` form. Registration rejects a missing declaration or unsupported raw schema; there is no content-return compatibility path.
|
||||
|
||||
For each successful dispatch the registry snapshots the returned value as lossless `JsonValue`, validates it against `output.schema`, deep-freezes it, then invokes the pure renderer and, for a direct surface call, the optional metadata projector. Renderer, projector, schema, or lossless-JSON failures are contained as ordinary `ToolOutputError` results. An around `tools/execute` wrapper receives and returns the canonical success/failure union; a wrapper-authored success is normalized again through the resolved tool's output declaration instead of trusting independently authored content. Canonical-result provenance is scoped to the immutable dispatch token, so returning a cached result from another call or tool triggers normalization under the active declaration rather than bypassing it.
|
||||
|
||||
```ts ignore-check
|
||||
type ToolExecutionResult =
|
||||
| { isError: false; value: JsonValue; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] }
|
||||
| { isError: true; error: { message: string; info?: { name: string; code: string } }; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] }
|
||||
```
|
||||
|
||||
`tools/post-execute` has two mutually exclusive successful projections. Replacing `content` changes only Native/model presentation and preserves the canonical value and metadata. Replacing `value` revalidates the replacement and recomputes both presentation projections. A block removes the value and becomes a failure. Content replacement is therefore not a confidentiality mechanism: policy that must prevent programmatic access blocks the call or replaces the value.
|
||||
|
||||
Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; Code Mode's `tool/code-dispatch` persists only its bounded summary. Neither event stores the intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata or result card. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context.
|
||||
|
||||
The first-party tools preserve their existing Native text while returning domain DTOs:
|
||||
|
||||
| Tool family | Canonical value |
|
||||
|---|---|
|
||||
| `read` | `{ path, offset, lines: [{ number, text }], totalLines }` |
|
||||
| `write` | `{ path, operation: "create" | "update", before: string | null, after }` |
|
||||
| `edit` | `{ path, before, after }` |
|
||||
| `glob` | `{ paths: string[] }` |
|
||||
| `grep` | `{ matches: [{ path, lineNumber, line }] }` |
|
||||
| `web_search` / `web_fetch` | The normalized `WebSearchResult` / `WebFetchResult` |
|
||||
| `lsp` | `{ kind: "locations", locations, resolvedWorkspaceRoot }` or `{ kind: "hover", hover }` |
|
||||
| `bash` | `{ kind: "background", taskId }` or `{ kind: "foreground" } & BashRunResult` |
|
||||
| `terminal_open` / `terminal_list` / `terminal_send` / `terminal_read` / `terminal_signal` / `terminal_close` | Public session snapshots, bounded read/send DTOs, signal/close outcomes, or a background task handle |
|
||||
| `task_output` / `task_list` / `task_kill` | Public task snapshots without owner or notification bookkeeping |
|
||||
| `subagent` | Background task handle or `{ kind: "foreground", runId, output: JsonValue[] }` |
|
||||
| `workflow` / `ralph` | `{ runId, agentsStarted, result: JsonValue }` |
|
||||
| `skill` | `{ name, provider, resourceBase?, content }` |
|
||||
| `todo_write` | `{ todos, counts }` |
|
||||
| `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` |
|
||||
| `exit_plan_mode` | `{ approved: true }` |
|
||||
| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | Inspection text or typed dynamic-mount handles |
|
||||
| `structured_output` | `{ recorded: true }` |
|
||||
| `run_code` | `{ logs: string[], result?: JsonValue }` |
|
||||
|
||||
Provider and executor acquisition limits remain real limits on the canonical value. Formatting-only limits belong in `render`; `glob` and `grep`, for example, keep every acquired item in `value` while their Native projection retains and best-effort spills the configured first page. Generic spill prepends and delegates its post-execute listener so an ordinary tool-owned asynchronous projection completes before generic byte bounding regardless of plugin load order. Filesystem mutations derive replayable diff metadata from `args` and the canonical before/after value rather than returning UI state from the body.
|
||||
|
||||
MCP bridges preserve protocol blocks through `McpResult<{...}> = { content: JsonValue[]; structuredContent? }`. An advertised `outputSchema` is enforced when it belongs to the supported raw subset; unsupported schemas fall back to `JsonValue` rather than pretending to validate them. Native rendering still uses the existing MCP-to-`ContentBlock` projection, and MCP `isError` becomes a failed tool result.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Return rendered text to Code Mode:** rejected because callers would continue scraping prose for task ids, mount ids, paths, and structured provider results.
|
||||
- **Persist canonical values on `tool/result`:** rejected because nested execution values are not model history, need not survive replay, and would create a session-format and storage commitment unrelated to Native reconstruction.
|
||||
- **Let tools return both value and content:** rejected because two author-owned results can disagree and policy cannot state which one is authoritative. The renderer makes presentation a deterministic projection of the validated value.
|
||||
- **Treat content replacement as value redaction:** rejected because presentation and programmatic access are different consumers; hiding only the former would create a false security boundary.
|
||||
- **Require object-rooted tool outputs:** rejected because scalar, array, and null results are legitimate JSON APIs. Object-rooting remains a consumer rule for caller-defined subagent/workflow structured output.
|
||||
|
||||
## Consequences
|
||||
|
||||
Native and replay behavior remains content-first and byte-compatible, while execution-time callers can use a validated domain value without parsing that content. Failures have one required message plus optional internal class/code information, successful and failed outcomes are discriminated, and a failed result can never promise a value. Tool authors must design the value and Native projection together; the extra declaration is intentional because it prevents accidental programmatic contracts from being inferred from prose.
|
||||
|
||||
Intermediate values remain bounded only by the producing capability and process memory. Their omission from the log means replay cannot recover them, and a content-only post policy does not hide them. These are explicit properties of the execution-local contract, not accidental gaps.
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# Agent Note:规范工具输出契约
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-canonical-tool-output-contract.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
工具主体过去直接编写面向模型的 `ContentBlock[]`,并可选择将其与不透明的 `meta` 包装在一起。因此,Native 模式的 Function Calling(函数调用)虽然拥有可供人阅读的投影,但程序化调用方没有稳定的领域值:Code Mode 会将内容块重新展平为字符串,动态工具会重复定义内容形态,策略也可以替换展示内容,却无法区分这项变更究竟是替换展示,还是替换操作结果。多个能力 seam 已经返回了信息更丰富的提供方值,却又在面向模型的工具边界丢弃这些值。
|
||||
|
||||
持久会话契约将这份展示内容视为回放时的权威来源,但如果持久化每一个信息丰富的中间值,就会扩大日志、使实现数据进入压缩和迁移流程,还会错误地把执行期本地 API 变成会话格式的一部分。因此,系统底层需要在执行期间保留一个类型化值,并显式将其投影为现有的持久化内容和模型可见内容。
|
||||
|
||||
## 决策
|
||||
|
||||
每个工具都必须声明规范输出,并且只能返回该声明描述的值:
|
||||
|
||||
```ts ignore-check
|
||||
output: {
|
||||
schema: OutputSchema
|
||||
render(args, value): ContentBlock[]
|
||||
presentationMeta?(args, value): JsonValue
|
||||
}
|
||||
```
|
||||
|
||||
`defineTool` 从统一的 `ValueSchemaSpec` 推导工具主体返回值和两个投影器的类型。原始定义和动态定义则提供编译后的 `JsonSchemaNode` 形式。注册时会拒绝缺失输出声明或采用不受支持原始 schema 的定义,不提供兼容旧式内容返回值的路径。
|
||||
|
||||
每次成功分发时,注册表会将返回值快照为无损 `JsonValue`,依据 `output.schema` 校验并深度冻结,然后调用纯渲染器;对于直接的外层调用,还会调用可选的元数据投影器。渲染器、投影器、schema 或无损 JSON 处理失败都会被收敛为普通 `ToolOutputError` 结果。around `tools/execute` 包装层接收并返回规范的成功/失败联合;包装层自行产生的成功结果会再次通过已解析工具的输出声明完成归一化,而不会信任其独立编写的内容。每个规范结果只归属于一个不可变的分发 token;因此,如果包装层返回来自其他调用或工具的缓存结果,系统会依据当前生效的输出声明重新执行归一化,而不会绕过这一步。
|
||||
|
||||
```ts ignore-check
|
||||
type ToolExecutionResult =
|
||||
| { isError: false; value: JsonValue; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] }
|
||||
| { isError: true; error: { message: string; info?: { name: string; code: string } }; content: ContentBlock[]; meta?: JsonValue; additionalContexts?: HookContext[] }
|
||||
```
|
||||
|
||||
`tools/post-execute` 为成功结果提供两种互斥的投影方式。替换 `content` 只改变 Native/模型展示,并保留规范值和元数据。替换 `value` 会重新校验替代值,并重新计算两份展示投影。阻止操作会移除值并转为失败。因此,替换内容并不是保密机制:必须阻止程序化访问的策略,应当阻止调用或替换值。
|
||||
|
||||
规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;Code Mode 的 `tool/code-dispatch` 只持久化其有界摘要。两个事件都不存储中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据或结果卡片。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的输出落盘投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。
|
||||
|
||||
第一方工具在保持现有 Native 文本不变的同时返回领域 DTO:
|
||||
|
||||
| 工具系列 | 规范值 |
|
||||
|---|---|
|
||||
| `read` | `{ path, offset, lines: [{ number, text }], totalLines }` |
|
||||
| `write` | `{ path, operation: "create" | "update", before: string | null, after }` |
|
||||
| `edit` | `{ path, before, after }` |
|
||||
| `glob` | `{ paths: string[] }` |
|
||||
| `grep` | `{ matches: [{ path, lineNumber, line }] }` |
|
||||
| `web_search` / `web_fetch` | 归一化后的 `WebSearchResult` / `WebFetchResult` |
|
||||
| `lsp` | `{ kind: "locations", locations, resolvedWorkspaceRoot }` 或 `{ kind: "hover", hover }` |
|
||||
| `bash` | `{ kind: "background", taskId }` 或 `{ kind: "foreground" } & BashRunResult` |
|
||||
| `terminal_open` / `terminal_list` / `terminal_send` / `terminal_read` / `terminal_signal` / `terminal_close` | 公开会话快照、有界的读取/发送 DTO、信号/关闭操作结果,或后台任务句柄 |
|
||||
| `task_output` / `task_list` / `task_kill` | 不含所有者或通知账务字段的公开任务快照 |
|
||||
| `subagent` | 后台任务句柄或 `{ kind: "foreground", runId, output: JsonValue[] }` |
|
||||
| `workflow` / `ralph` | `{ runId, agentsStarted, result: JsonValue }` |
|
||||
| `skill` | `{ name, provider, resourceBase?, content }` |
|
||||
| `todo_write` | `{ todos, counts }` |
|
||||
| `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` |
|
||||
| `exit_plan_mode` | `{ approved: true }` |
|
||||
| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | 检查文本或类型化的动态挂载句柄 |
|
||||
| `structured_output` | `{ recorded: true }` |
|
||||
| `run_code` | `{ logs: string[], result?: JsonValue }` |
|
||||
|
||||
提供方和执行器的采集上限仍会实际限制规范值。仅用于格式化的限制归 `render` 所有;例如,`glob` 和 `grep` 会在 `value` 中保留所有已采集项,而其 Native 投影仍只保留配置指定的第一页,并尽力将完整展示内容写入落盘文件。通用落盘机制会前置注册其 post-execute 监听器,并让该监听器先向后委托,因此无论插件加载顺序如何,普通工具自有的异步投影都会在通用字节数上限处理之前完成。文件系统变更工具根据 `args` 和规范的变更前/后值推导可回放的 diff 元数据,不再由工具主体返回 UI 状态。
|
||||
|
||||
MCP 桥接层通过 `McpResult<{...}> = { content: JsonValue[]; structuredContent? }` 保留协议内容块。当公布的 `outputSchema` 属于受支持的原始子集时,系统会强制校验;不受支持的 schema 则回退为 `JsonValue`,而不会假装已完成校验。Native 渲染仍使用现有的 MCP 到 `ContentBlock` 投影,MCP `isError` 则会变为失败的工具结果。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **向 Code Mode 返回渲染后的文本:**不予采纳。调用方仍需从自然语言中提取 task id、挂载 id、路径和结构化提供方结果。
|
||||
- **在 `tool/result` 上持久化规范值:**不予采纳。嵌套执行值不属于模型历史记录,无需在回放后继续存在;持久化还会引入与 Native 重建无关的会话格式和存储承诺。
|
||||
- **允许工具同时返回值和内容:**不予采纳。由作者分别维护的两份结果可能互相矛盾,策略也无法说明哪一份才是权威结果。渲染器会根据已校验值确定性地产生展示。
|
||||
- **将内容替换视为值脱敏:**不予采纳。展示内容和程序化访问面向不同消费方;只隐藏前者会制造虚假的安全边界。
|
||||
- **要求工具输出必须以对象为根:**不予采纳。标量、数组和 null 结果都是合理的 JSON API。只有由调用方定义的 subagent/工作流结构化输出仍受消费方的对象根规则约束。
|
||||
|
||||
## 影响
|
||||
|
||||
Native 和回放行为仍以内容为先,并保持逐字节兼容;执行期调用方则无需解析内容,即可使用经过校验的领域值。失败结果必须包含消息,并可选择附加内部类名/代码信息;成功与失败结果由判别字段区分,失败结果绝不会承诺存在值。工具作者必须一并设计值及其 Native 投影;增加这项声明是有意为之,因为它避免从自然语言内容意外推导出程序化契约。
|
||||
|
||||
中间值只受产生它们的能力和进程内存限制。日志不包含这些值,因此回放无法恢复;仅处理内容的 post 策略也无法隐藏这些值。这些都是执行期本地契约的明确属性,并非意外缺口。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-unified-json-value-schema-dsl.md: 09945c413ffe5924c74076648cdf3da60c3e18c9
|
||||
2026-07-20-unified-json-value-schema-dsl.zh.md: 00a7a199613ea857a7815f1c7794781f143a3896
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Unified JSON-value schema DSL
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-unified-json-value-schema-dsl.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Tool parameters used a small author DSL while subagent/workflow structured output used a separate raw JSON Schema subset and validator. The two vocabularies disagreed about roots, scalar constraints, and validation, so a typed canonical tool-output contract would either duplicate both paths again or accept schemas that some projection could not enforce.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tools` owns one JSON-value schema vocabulary with two representations. `ValueSchemaSpec` is the author form for any JSON root; `ParameterSchemaSpec` is its implicit object-property-map form with per-property `required: true`. `JsonSchemaNode` is the raw wire form. Both support string, finite number, integer, boolean, null, array, object, type-correct scalar `enum`/`const`, and exact-one `oneOf`; `{ type: 'json' }` is author-only sugar for an annotation-only unconstrained raw node.
|
||||
|
||||
An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. Schema records contain only own enumerable string keys, schema arrays are dense intrinsic arrays, and supported keywords are read as own properties; custom prototypes, inherited constraints, symbols, and JSON-invisible decorations therefore cannot make compilation, projection, and validation observe different declarations. Intrinsic plain Object and Array containers remain plain across JavaScript realms, while subclasses and forged constructor prototypes remain exotic.
|
||||
|
||||
`InferValue<S>` and `InferArgs<P>` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. Exact inference is bounded to 16 container levels and then uses `JsonValue`, preventing TypeScript's type-instantiation stack from becoming the authoring limit. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Author compilation, raw-schema assertion, value validation, schema-to-TypeScript rendering, registry detachment, and dynamic Cordis cross-realm normalization and cloning use explicit work stacks, so runtime nesting is limited by available memory rather than the JavaScript call stack.
|
||||
|
||||
Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler. The dynamic boundary rejects JSON-invisible record keys and exotic schema arrays before normalization, so it cannot silently discard a constraint or consume custom iteration semantics.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep separate parameter and structured-output schema systems:** rejected because every added output construct would require parallel inference, compilation, validation, and code-generation changes with no useful ownership boundary.
|
||||
- **Adopt full JSON Schema or Ajv:** rejected because the harness must fail on every construct it cannot project into its generated SDK and validators; accepting a larger language would make enforcement and model guidance dishonest.
|
||||
- **Make every object implicitly open or closed:** rejected because either choice hides a consequential author decision. Only the legacy-shaped implicit parameter root and external raw schema retain an intentional default.
|
||||
- **Define `oneOf` as first-match:** rejected because branch ordering would change validation semantics and allow overlapping branches to hide ambiguous values.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Parameter validation, output validation, schema-to-TypeScript generation, subagent/workflow guards, and dynamic registration share one enforced vocabulary.
|
||||
- Output declarations can infer object, array, scalar, or null roots; subagent/workflow structured outputs remain object-rooted at their existing seams.
|
||||
- Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call.
|
||||
- Bounded type inference retains useful exact types for ordinary declarations and degrades unusually deep tails to `JsonValue`; runtime schema enforcement remains exact at every depth.
|
||||
- Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them.
|
||||
- Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, inference, deep nesting across core and dynamic projections, JSON-invisible dynamic keys, and exotic schema arrays.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note:统一 JSON 值 schema DSL
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-unified-json-value-schema-dsl.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
工具参数使用一套精简的作者侧 schema DSL,subagent/工作流的结构化输出则使用另一套原始 JSON Schema 子集和校验器。两套词汇在根类型、标量约束和校验方式上并不一致;如果继续沿用这种划分,类型化的规范工具输出契约要么还需重复实现两条路径,要么只能接受部分投影无法强制执行的 schema。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。
|
||||
|
||||
显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。schema 记录只能包含自有且可枚举的字符串键,schema 数组必须是稠密的内建数组,系统只从自有属性读取受支持的关键字;因此,自定义原型、继承的约束、symbol 和 JSON 不可见的附加内容都无法让编译、投影和校验观察到不同的声明。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器,而子类和伪造构造函数的原型仍视为非普通对象。
|
||||
|
||||
`InferValue<S>` 和 `InferArgs<P>` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。精确类型推导以 16 层容器为界,超过后使用 `JsonValue`,从而避免 TypeScript 的类型实例化栈限制作者能声明的嵌套深度。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。作者侧 schema 编译、原始 schema 断言、值校验、schema 到 TypeScript 的渲染、注册表数据分离,以及动态 Cordis 的跨运行域规范化与克隆均使用显式工作栈,因此运行时嵌套只受可用内存限制,不受 JavaScript 调用栈限制。
|
||||
|
||||
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。动态边界会在规范化之前拒绝 JSON 不可见的记录键和非普通 schema 数组,因此不会静默丢弃约束,也不会触发自定义迭代逻辑。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **保留两套独立的参数与结构化输出 schema 系统:**不予采纳。每新增一种输出结构,都必须分别修改类型推导、编译、校验和代码生成,而这种重复并未形成有意义的职责边界。
|
||||
- **采用完整 JSON Schema 或 Ajv:**不予采纳。harness 必须拒绝所有无法投影到生成 SDK 和校验器中的结构;如果接受更大的语言子集,强制执行能力和模型指引就会与事实不符。
|
||||
- **让所有对象默认开放或默认封闭:**不予采纳。这两种选择都会隐藏一项影响重大的作者决策。只有保持旧有形态的隐式参数根对象和外部原始 schema 才有意保留默认值。
|
||||
- **把 `oneOf` 定义为首个匹配分支:**不予采纳。这样一来,分支顺序会改变校验语义,重叠分支也会掩盖值的歧义。
|
||||
|
||||
## 影响
|
||||
|
||||
- 参数校验、输出校验、schema 到 TypeScript 的代码生成、subagent/工作流门禁和动态注册共用一套强制执行的词汇。
|
||||
- 输出声明可以推导对象、数组、标量或 null 根类型;subagent/工作流的结构化输出仍在其现有服务边界保持对象根限制。
|
||||
- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。
|
||||
- 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。
|
||||
- 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。
|
||||
- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导、核心投影和动态投影中的深层嵌套、动态注册中 JSON 不可见的键,以及非普通 schema 数组。
|
||||
+2
-2
@@ -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
|
||||
2026-07-22-slot-type-chain-implementation.md: b4ec761b9777f5dfbd59efde8c472f9be4c2e1b6
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 28b6e4a3db0c87322582125825492703e62371b2
|
||||
2026-07-22-slot-type-chain-implementation.md: 1e9bd711e8316e2556fe238eb0a20d76e1d0d5b1
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 0eab839d033faac2f2c3356900c7ca1cd69d2dc9
|
||||
+81
-19
@@ -1,47 +1,109 @@
|
||||
# Agent Note: Slot type-chain hardening — the non-obvious implementation rulings
|
||||
# Agent Note: The slot system standard — single register, four props shares, and the framework store seat
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-slot-type-chain-implementation.zh.md)
|
||||
|
||||
> Scope: why the slot registration/render type chain (`packages/client/ui-slots/src/index.ts`, consumed by `packages/client/web-react/src/scoped-slots.tsx`) is implemented the way it is. The design-level trade-offs (registration-site inference over declaration tables, hand-written whitelists over derived ones) live in the web client architecture RFC; this note pins the five implementation decisions a future editor would otherwise re-litigate or accidentally revert.
|
||||
> Scope: the definitive slot-system design for the web client — how UI plugins compose the page, where render authority lives, how component props are typed, and where business live-data goes. The [web client architecture RFC](2026-07-19-gui-web-client-architecture.md) owns the surrounding context (loading chain, object layer, services) and defers its slot sections here.
|
||||
|
||||
## Problem
|
||||
|
||||
The hardened chain types every hop from `SlotMap` declaration to rendered component: owner share + framework-standard share + registrant-injected share compose into the component's props, checked at `register()`. Making that constraint hold without false rejections forced five choices that look arbitrary from the code alone — each one exists because the obvious alternative fails in a specific, reproducible way.
|
||||
The page is composed at runtime from independently loaded plugins, so the UI needs a composition mechanism that answers four questions with static force. Who may render into a region — and is that authority enforceable, or merely conventional? How does a component receive everything it needs while staying a pure function (no ctx, no framework imports), without every value being hand-threaded through assembly code? Where does business live-data live so that streaming updates re-render precisely the subscribers — without every plugin building its own subscription machinery? And how much of this can the compiler check, so that a drifted component, an over-reaching render call, or a mismatched store schema is a compile error at one visible call site rather than a runtime surprise?
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. `SlotComponent<P>` (bare call signature) instead of `FC<P>` at the registration position
|
||||
One sentence: **the shell renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.**
|
||||
|
||||
`register()` constrains components as `SlotComponent<ComposedProps<K, NoInfer<I>>>` where `SlotComponent<P> = (props: P) => ReactNode`. React's `FC` carries static fields (`propTypes`, `defaultProps`) whose types reference `P` in covariant positions; assignability between two `FC` instantiations therefore checks those statics too, and the bottom-typed standard share (see ruling 4's `useSession: never`) makes those covariant checks reject components that narrow it — precisely the components the design wants to accept. The bare call signature checks through clean parameter contravariance only. Components stay ordinary functions; nothing observable changes at runtime.
|
||||
### 'root' is the only a-priori slot
|
||||
|
||||
### 2. `NoInfer<I>` pins the registrant share's inference to the inject factory
|
||||
`SlotsService` (client runtime) declares `'root'` at construction — single/root, `owner: {}` — and its `SlotMap` merge lives in the runtime package. The shell's entire assembly is `ctx.slots.renderSlot('root', {})`: the only ctx-level render entry; any other key, a missing renderer, or an unregistered root fails loud (no fallback).
|
||||
|
||||
`I` (the registrant's injected share) must be inferred from the `inject` factory's return type — the single authoritative source. Without `NoInfer`, TS also collects inference candidates from the component parameter position, and a drifted component (consuming a key the factory does not supply) silently WIDENS `I` to make the call check, absorbing the drift instead of reporting it. `NoInfer<I>` at the component position removes that candidate site, so negative sample ⑥ (a hand-drifted copy of the owner share fails at `register`) actually fails — with inference bleed it would pass. If the `NoInfer` ever gets "simplified away", the type-chain spec's expect-error site goes red first.
|
||||
### register is the single API; children = declaration + authorization + runtime spec
|
||||
|
||||
### 3. `ComposedProps` dispatches on the entry's `owner` key for progressive migration
|
||||
```ts ignore-check
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'sidebar': { kind: 'single', scope: 'root' },
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
store: createLayoutStore, // StoreHandle or factory (below)
|
||||
inject: injectFrame, // business face (below)
|
||||
}, AppFrame)
|
||||
```
|
||||
|
||||
`ComposedProps<K, I>` composes `owner & standard & I` only when the SlotMap entry declares an `owner` share; entries without one fall back to the legacy full-`props` constraint (`PropsShape`). This conditional is the migration seam: legacy declarations keep compiling unchanged while entries opt into the composed model one at a time, and both forms flow through the same `register()` overload — no parallel API, no flag. Removing the fallback branch is the flip-the-switch moment for the whole repo, not a cleanup.
|
||||
There is no separate slot-definition API. The `children` object both **declares the child slots into existence** and **authorizes this component to render them** — a slot is a hole in the render tree that exists because someone will render it, so its lifecycle is the declaring entry's lifecycle (entry disposed → slots gone, contributions cleared). The values are the runtime spec (`kind`/`scope` drive outlet iteration and binding selection; `SlotMap` is types-only and erased at runtime, which is why an array of keys could not work), statically checked against the `SlotMap` entry so type and value are declared at one point and cross-validated.
|
||||
|
||||
### 4. The standard share is bottom-typed, and bare `register` bivariance is accepted, not fought
|
||||
Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes.
|
||||
|
||||
Session slots' framework-supplied hook is constrained as `{ useSession: never }` (`StandardOf`): `never` in a parameter-ish position means any registrant narrowing (e.g. a runtime-typed conversation hook) is accepted, and the responsibility for what actually arrives lives with the injecting renderer. Known boundary rider: for components typed with METHOD syntax or otherwise bivariant parameter positions, TS can accept a `register` call it strictly shouldn't (parameter bivariance is unsound by design in TS). The accepted stance is documented rather than tested: we do not add negative samples that depend on strictness TS does not guarantee — they would pin compiler-version behavior, not our contract. The samples we do pin (six expect-error sites in `packages/client/ui-slots/tests/type-chain.spec.tsx`) all fail for contract reasons.
|
||||
`SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type").
|
||||
|
||||
### 5. `ChildrenChecked` is an opt-in validation layer keyed on the entry's `children` declaration
|
||||
### Component props: four shares, each from its own source of truth
|
||||
|
||||
Sub-slot delegation authority stays a hand-written whitelist (`slots: ScopedSlots<'a' | 'b'>` in the component's own props). `ChildrenChecked<K, P>` adds an optional second check: only when the entry declares `children` does the component's `slots` face get validated against the authorized union (violation collapses `slots` to `never`, surfacing at the register call). Entries without `children` pass through untouched. The hook point is inside `ComposedProps` — i.e. it fires exactly at the registration boundary, not at render — because register is where both halves (entry declaration, component face) are statically visible at once; a render-time check would need runtime plumbing for a purely static guarantee.
|
||||
| Share | Type | Source of truth | Contents |
|
||||
|---|---|---|---|
|
||||
| runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions` |
|
||||
| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S |
|
||||
| store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
|
||||
| business | `I` | inject return type | plain data + callbacks (hooks banned) |
|
||||
|
||||
`sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API.
|
||||
|
||||
### The store seat: framework engine, registrant schema
|
||||
|
||||
The framework owns exactly one subscription machine: the snapshot store engine (zustand vanilla + immer + optional localStorage persistence) lives in the **runtime package** (`./client` main entry — no subpath), producing bare observable sources; web-react binds them into hooks at the outlet (per-source cached uSES binding). What a store *contains* is the registrant's declaration, written as a factory so no module-level handle exists (a module-scoped handle would be a de-facto singleton surviving plugin reloads):
|
||||
|
||||
```ts ignore-check
|
||||
export function createChatStore() {
|
||||
return defineStore({
|
||||
init: () => ({ selection: null as SelectionTarget | null, draft: '' }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
select: (d, t: SelectionTarget) => { d.selection = t },
|
||||
clearDraft:(d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
### inject: the registrant's business face, on its own ctx
|
||||
|
||||
An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
|
||||
|
||||
### Data-boundary discipline
|
||||
|
||||
Hooks are framework-made only: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
|
||||
|
||||
### Tree context and the renderer seam
|
||||
|
||||
`SessionProvider` is a framework component **delivered as a standard-kit seat**: an entry whose `children` declare a session-scope slot receives it as a prop (type in ui-slots, value injected by the renderer) — components never value-import it. It is self-wired (it reads the runtime's current-session state internally; the assembler passes nothing), render-prop shaped — `children(sessionId)` with an `empty` branch, remounting under `key={sessionId}`. `BindingContext` is machinery-internal; business components see zero React contexts. Inject factories execute inside the outlet on purpose (per-entry error boundaries catch them; a crashing registrant blacks out only its own entry while assembly errors rethrow); the outlet reads tree context as a machinery-only implicit parameter — the "identity from the register closure, situation from the tree position" split.
|
||||
|
||||
Rendering lives behind an install seam so the runtime stays React-free: `SlotRenderer` (interface in ui-slots, implementation `createSlotRenderer()` in web-react) is installed once at shell boot via `ctx.slots.install(...)`; double install and render-before-install throw. Ownership bookkeeping is a single `Map<key, entry>` in the service — ledger, slots, contributions, render bindings, and store instances all live and die on the one entry axis, which closes the stale-authority window across plugin reloads by construction (a disposed entry's captured `renderSlot` throws a stale-authorization error on entry).
|
||||
|
||||
### Type-chain implementation rulings
|
||||
|
||||
Two hardening decisions in the register signature exist because the obvious alternative fails in a specific, reproducible way; a future editor should not re-litigate them:
|
||||
|
||||
1. **`SlotComponent<P>` (bare call signature) instead of `FC<P>` at the registration position.** React's `FC` carries static fields (`propTypes`, `defaultProps`) whose types reference `P` in covariant positions; assignability between two `FC` instantiations checks those statics too and rejects components the design wants to accept. The bare call signature checks through clean parameter contravariance only; components stay ordinary functions.
|
||||
2. **`NoInfer<I>` pins the business share's inference to the inject factory.** Without it, TS also collects inference candidates from the component parameter position, and a drifted component (consuming a key the factory does not supply) silently widens `I` to make the call check — absorbing exactly the drift the chain exists to catch. The negative-sample spec pins this: if the `NoInfer` is ever "simplified away", the expect-error site goes red first.
|
||||
|
||||
## Consequences
|
||||
|
||||
The register call site is now the chain's single choke point: share drift, missing inject keys, unauthorized sub-slot faces, and keyed/list option omissions all surface there at compile time, and the six-sample negative spec pins each failure mode. Costs: the conditional types make hover-signatures at register sites noticeably wider; the bottom-typed standard share shifts arrival-type responsibility onto web-react's renderer (documented on `StandardOf`); and the bivariance boundary means one unsound-accept class is knowingly tolerated.
|
||||
Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading 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.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| Keep `FC` and cast at register sites | The casts hide exactly the drift the chain exists to catch; FC statics' covariant noise is the mechanical cause, so remove the noise, not the check |
|
||||
| Infer `I` from the component parameter | Inference bleed absorbs props drift silently — negative sample ⑥ becomes unwritable |
|
||||
| Big-bang migration to composed props | Every SlotMap declarant lands in one PR; the `owner`-keyed conditional lets entries migrate one by one with both forms live |
|
||||
| Test the bivariant-accept edge as a negative sample | Would pin TS soundness behavior we don't own; compiler upgrades would break the spec without any contract change |
|
||||
| Derive delegation whitelists from `children` declarations | The hand-written face is the API the component author reads; derivation inverts ownership and was rejected at design level — `ChildrenChecked` validates instead of generating |
|
||||
| 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 |
|
||||
| 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-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine |
|
||||
| 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 |
|
||||
| `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) |
|
||||
+81
-19
@@ -1,47 +1,109 @@
|
||||
# Agent Note: slot 类型链硬化——五条非显然实现裁定
|
||||
# Agent Note: slot 体系标准——单一 register、props 四份额与框架 store 席位
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-slot-type-chain-implementation.md) | 中文
|
||||
|
||||
> 范围:slot 注册/渲染类型链(`packages/client/ui-slots/src/index.ts`,消费方 `packages/client/web-react/src/scoped-slots.tsx`)为什么这样实现。设计层取舍(注册点推断优于声明表、手写白名单优于派生)住 Web 客户端架构 RFC;本文钉住五条实现决定——不写下来,将来的编辑者要么重新争论一遍,要么不经意地回退它们。
|
||||
> 范围:Web 客户端 slot 体系的终版设计——UI 插件如何拼合页面、渲染权威落在哪里、组件 props 如何定型、业务活数据住在哪里。周边语境(装载链、对象层、服务)归 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md) 所有,其 slot 各节移交本文。
|
||||
|
||||
## Problem
|
||||
|
||||
硬化后的类型链给从 `SlotMap` 声明到组件渲染的每一跳定型:owner 份额 + 框架标配份额 + 注册方注入份额组合成组件 props,在 `register()` 处校验。让这条约束既成立又不误伤,逼出了五个单看代码显得任意的选择——每一个的存在都是因为显然的替代方案会以一种具体的、可复现的方式失败。
|
||||
页面在运行时由各自独立装载的插件拼合而成,UI 因此需要一套能以静态强制力回答四个问题的组合机制。谁可以渲染进某块区域——这份权威是可强制执行的,还是仅靠约定?组件如何在保持纯函数(零 ctx、零框架 import)的同时拿到它需要的一切,而不必把每个值都经装配代码手工穿线?业务活数据住在哪里,才能让流式更新恰好只重渲染订阅者——而不必每个插件自建一套订阅机械?以及这一切有多少能交给编译器检查,让漂移的组件、越权的渲染调用、错配的 store schema 成为单一可见调用点上的编译错误,而非运行时的意外?
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. 注册位用 `SlotComponent<P>`(裸调用签名)而非 `FC<P>`
|
||||
一句话:**壳只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占坑、声明并授权子坑、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。**
|
||||
|
||||
`register()` 以 `SlotComponent<ComposedProps<K, NoInfer<I>>>` 约束组件,其中 `SlotComponent<P> = (props: P) => ReactNode`。React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性因此连这些静态位一起查,而 bottom 型的标配份额(见裁定 4 的 `useSession: never`)使这些协变检查拒绝掉收窄它的组件——恰恰是设计想接受的那批组件。裸调用签名只走干净的参数逆变检查。组件仍是普通函数;运行时零可见差异。
|
||||
### 'root' 是唯一的先验坑
|
||||
|
||||
### 2. `NoInfer<I>` 把注册方份额的推断钉在 inject 工厂上
|
||||
`SlotsService`(client 运行时)在构造时声明 `'root'`——single/root、`owner: {}`——其 `SlotMap` 合并声明住 runtime 包(package)。壳的全部装配就是 `ctx.slots.renderSlot('root', {})`:唯一的 ctx 级渲染入口;传任何其他键、渲染器未安装、root 无人注册,一律大声失败(无 fallback)。
|
||||
|
||||
`I`(注册方注入份额)必须从 `inject` 工厂的返回类型推断——唯一权威源。没有 `NoInfer` 时,TS 还会从组件参数位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默地把 `I` 加宽到让调用通过,把漂移吸收掉而不是报出来。组件位的 `NoInfer<I>` 移除了那个候选位,负样本⑥(owner 份额的手抄漂移件在 register 处失败)才得以成立——有推断渗漏时它会通过。将来若有人把这个 `NoInfer`「顺手简化」掉,类型链 spec 的 expect-error 位会第一个变红。
|
||||
### register 是唯一 API;children = 声明+授权+运行时 spec
|
||||
|
||||
### 3. `ComposedProps` 按条目的 `owner` 键分派,支撑渐进迁移
|
||||
```ts ignore-check
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'sidebar': { kind: 'single', scope: 'root' },
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
store: createLayoutStore, // StoreHandle or factory (below)
|
||||
inject: injectFrame, // business face (below)
|
||||
}, AppFrame)
|
||||
```
|
||||
|
||||
`ComposedProps<K, I>` 只在 SlotMap 条目声明了 `owner` 份额时才组合 `owner & standard & I`;未声明的条目回落到 legacy 全量 `props` 约束(`PropsShape`)。这个条件类型就是迁移接缝:legacy 声明原样编译,条目逐个转入组合模型,两种形态走同一个 `register()`——无平行 API、无开关旗。删掉回落分支的那一刻=全仓切换时刻,不是一次清理。
|
||||
不存在独立的坑位定义 API。`children` 对象同时做两件事:**把子坑声明出来**,并**授权本组件渲染它们**——坑是渲染树上的一个洞,因为有人要渲染它才存在,所以坑的生命周期就是声明它的 entry 的生命周期(entry 一经 dispose(资源释放),坑随之消亡、坑内既有贡献清空)。children 的值是运行时 spec(`kind`/`scope` 驱动 outlet 的迭代形态与 binding 选择;`SlotMap` 是纯类型、运行时即被擦除,这正是键数组形行不通的原因),并与对应 `SlotMap` entry 静态对齐校验——类型与值在同一点声明、交叉验证。
|
||||
|
||||
### 4. 标配份额 bottom 型化;裸 `register` 的双变接受面认账不硬测
|
||||
对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下。
|
||||
|
||||
session 坑的框架供给 hook 约束为 `{ useSession: never }`(`StandardOf`):参数性位置上的 `never` 意味着任何注册方收窄(如 runtime 定型的会话 hook)都被接受,实际到达什么的类型责任归注入侧渲染器。已知边界搭车项:对以方法语法定型或参数位本就双变的组件,TS 可能接受一个严格意义上不该过的 `register` 调用(参数双变是 TS 的有意不健全)。这个立场以文档记账而不加测试:我们不写依赖 TS 并不承诺的严格性的负样本——那钉住的是编译器版本行为,不是我们的契约。真正钉住的六个 expect-error 位(`packages/client/ui-slots/tests/type-chain.spec.tsx`)全部因契约原因失败。
|
||||
`SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。
|
||||
|
||||
### 5. `ChildrenChecked` 是按条目 `children` 声明挂载的 opt-in 校验层
|
||||
### 组件 props:四份额,各有唯一真源
|
||||
|
||||
子坑转授权威仍是手写白名单(组件自己 props 上的 `slots: ScopedSlots<'a' | 'b'>`)。`ChildrenChecked<K, P>` 加一层可选的第二道检查:仅当条目声明了 `children`,组件的 `slots` 面才对照授权并集校验(越界时 `slots` 坍缩为 `never`,在 register 调用处暴露)。未声明 `children` 的条目原样通过。挂点选在 `ComposedProps` 内部——即恰好在注册边界而非渲染期起效——因为 register 是条目声明与组件面两个半边同时静态可见的唯一位置;渲染期检查要为一个纯静态保证铺运行时管线。
|
||||
| 份额 | 类型 | 真源 | 内容 |
|
||||
|---|---|---|---|
|
||||
| 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` |
|
||||
| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S |
|
||||
| store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
|
||||
| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) |
|
||||
|
||||
凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
|
||||
|
||||
### store 席位:引擎归框架,schema 归注册方
|
||||
|
||||
框架拥有恰好一台订阅机械:快照 store 引擎(zustand vanilla + immer + 可选 localStorage 持久化)住 **runtime 包**(`./client` 主出口——无子路径),产出裸的可观察源;web-react 在 outlet 处把它们绑定成 hook(按源缓存的 uSES 绑定)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例):
|
||||
|
||||
```ts ignore-check
|
||||
export function createChatStore() {
|
||||
return defineStore({
|
||||
init: () => ({ selection: null as SelectionTarget | null, draft: '' }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
select: (d, t: SelectionTarget) => { d.selection = t },
|
||||
clearDraft:(d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
一个工厂,三个消费点:① `register`——独占 store 直接传工厂;要共享实例,则在 `apply` 里调用一次工厂、把同一句柄传给多次 register(跨插件共享构造性不可能:句柄从不出包);② `PropsStore<ReturnType<typeof createChatStore>>` 推导出组件的 store 份额,零手写成员;③ 测试自己调用工厂并 `.create()` 出真引擎实例,把 `useSelector`/`actions` 直接当 props 喂进去——生产 outlet 走的正是同一条 `create` 路径,不存在第二套机械。
|
||||
|
||||
store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会话一个实例,随会话生灭;root 坑→每个 entry 一个)。读 = `props.useStore`;写 = 仅 `props.actions.*`——裸实例(带 `update`/`set`)永远到不了组件,声明的 actions 就是完整且可审计的变更面。生产代码在 `apply` 之外从不调用工厂或 `create`。
|
||||
|
||||
### inject:注册方的业务面,立足自己的 ctx
|
||||
|
||||
inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
|
||||
|
||||
### 数据界线纪律
|
||||
|
||||
hook 只许框架造:`useSession`、`useSessions`、`useStore`、`renderSlot` 是仅有的四席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
|
||||
|
||||
### 树上语境与渲染器安装缝
|
||||
|
||||
`SessionProvider` 是框架组件,**以标配席形式送达**:`children` 里声明了 session scope 坑的 entry 经 prop 收到它(类型住 ui-slots,值由渲染器注入)——组件永不对它做值 import。它框架自接线(内部自读 runtime 的当前会话状态,装配方零传参),render-prop 形——`children(sessionId)` 外加 `empty` 分支,以 `key={sessionId}` 重挂。`BindingContext` 属机械内部;业务组件可见的 React Context 为零。inject 工厂有意在 outlet 内部执行(per-entry 错误边界接得住它们;崩溃的注册方只黑掉自己那一格,装配错误则重抛);outlet 把树上语境当作仅机械可用的暗参读取——即「身份出自 register 闭包、现场出自树位置」的分工。
|
||||
|
||||
渲染住在一条安装缝之后,runtime 因此保持 React-free:`SlotRenderer`(接口住 ui-slots,实现 `createSlotRenderer()` 住 web-react)在壳 boot 时经 `ctx.slots.install(...)` 安装一次;双重安装与安装前渲染均 throw。归属记账是服务里的单一 `Map<key, entry>`——账本、坑、贡献、渲染绑定、store 实例全部沿同一条 entry 轴生灭,跨插件重载的陈旧权威窗口由此在构造上关闭(已 dispose 的 entry 所捕获的 `renderSlot`,一进入口即抛陈旧授权(stale-authorization)错误)。
|
||||
|
||||
### 类型链实现裁定
|
||||
|
||||
register 签名里的两条硬化裁定之所以存在,是因为显然的替代方案会以具体、可复现的方式失败;将来的编辑者不应重新争论它们:
|
||||
|
||||
1. **注册位用 `SlotComponent<P>`(裸调用签名)而非 `FC<P>`。** React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性检查连这些静态位一起查,会拒绝设计本想接受的组件。裸调用签名只走干净的形参逆变检查;组件仍是普通函数。
|
||||
2. **`NoInfer<I>` 把业务份额的推断钉在 inject 工厂上。** 没有它,TS 还会从组件形参位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默把 `I` 加宽到让调用通过——恰好吸收掉类型链本要抓的漂移。负样本 spec 钉住这一点:若这个 `NoInfer` 日后被「顺手简化」掉,expect-error 位会第一个变红。
|
||||
|
||||
## Consequences
|
||||
|
||||
register 调用点成为全链唯一收口:份额漂移、inject 键缺失、越权子坑面、keyed/list options 缺省全部在编译期于此暴露,六样本负样本 spec 逐一钉住失败模式。代价:条件类型让 register 位的悬停签名明显变宽;bottom 型标配份额把到达类型的责任转给 web-react 渲染器(记录于 `StandardOf`);双变边界意味着一类不健全接受被知情容忍。
|
||||
渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| 保留 `FC`、在 register 位 cast | cast 恰好藏起类型链要抓的漂移;FC 静态位的协变噪音是机械成因,该移除噪音而非移除检查 |
|
||||
| 从组件参数位推断 `I` | 推断渗漏静默吸收 props 漂移——负样本⑥无从写起 |
|
||||
| 组合 props 一次性全仓迁移 | 所有 SlotMap 声明方挤进一个 PR;`owner` 键分派让条目逐个迁移、两形态共存 |
|
||||
| 给双变接受边缘加负样本 | 钉住的是我们不拥有的 TS 健全性行为;编译器升级会在契约零变化时打红 spec |
|
||||
| 从 `children` 声明派生转授白名单 | 手写面才是组件作者读到的 API;派生反转所有权,设计层已否——`ChildrenChecked` 做校验不做生成 |
|
||||
| 独立的 define/register 两步式 API | 拆分让渲染权威无从强制、招来时序 bug;children 进 register 让声明、授权、spec 在同一个可见位置结清 |
|
||||
| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 |
|
||||
| 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 |
|
||||
| `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 |
|
||||
| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 |
|
||||
| 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
|
||||
| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
|
||||
| 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-tui-interactive-extension-service.md: 82e7c751b6e5b7500f9f7d7004fda8b905dccabb
|
||||
2026-07-22-tui-interactive-extension-service.zh.md: d7340e3f5dcf45e95b2d6e15ce3fc33726a555ae
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Effect-owned TUI interactive extensions
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-tui-interactive-extension-service.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Cordis plugins can register human commands through `ctx.commands`, but a command that needs terminal interaction has no supported presentation boundary. It must either remain non-interactive or capture the TUI's private pi-tui tree, focus state, renderer, and shutdown lifecycle. That coupling makes the extension depend on one front door's internals, lets independently developed overlays compete for focus, and leaves plugin unload with no reliable way to remove queued or visible UI.
|
||||
|
||||
## Decision
|
||||
|
||||
A mounted `@deepseek-ai/dsh-tui` provides `ctx.tui` after terminal startup succeeds. The service belongs to that exact terminal and agent, disappears before terminal teardown, and causes plugins that inject it to unload and reload with provider availability. Other front doors do not emulate it.
|
||||
|
||||
`ctx.tui.openOverlay()` is the first and only interactive extension primitive. It accepts a component factory, constrained layout options, and an optional abort signal. The factory receives a frozen host with the current viewport, semantic theme functions, display-text escaping, redraw, close, and a lifetime signal. It does not receive the pi-tui `TUI`, overlay handle, editor, transcript tree, focus controller, or terminal object.
|
||||
|
||||
One private overlay manager serializes built-in and plugin requests in FIFO order. The model selector and `ctx.userInteraction` question panel use the same manager, so all modal interaction has one focus owner. Closing the active overlay restores pi-tui's previous focus before the next request activates. Overlay state is process-local presentation: it is neither appended to the session log nor rebuilt during resume.
|
||||
|
||||
The service method runs through Cordis's traceable service proxy. It installs an effect on the calling plugin fiber before admitting the request; caller disposal therefore removes a queued request or closes an active overlay and awaits the same settled outcome. TUI shutdown first rejects admission, then disposes the service fiber so dependent plugins and their effects quiesce, settles remaining built-in work, and only then drains and stops the terminal.
|
||||
|
||||
Component construction, rendering, input, and invalidation run behind an exception boundary. A failure closes that request with an `error` outcome, reports a visible terminal error, and lets the queue continue. Components are trusted package code: their rendered lines may contain ANSI styling, and they must call `host.display()` before including untrusted text.
|
||||
|
||||
## Verification
|
||||
|
||||
Manager tests pin FIFO admission, cancellation, repeated close, shutdown outcomes, guarded callbacks, host capabilities, and per-file coverage. Cordis lifecycle tests pin caller ownership, provider loss and return, unloading-time rejection, and cleanup quiescence. Fake-terminal integration tests exercise plugin overlays alongside built-in questions, restored editor input, terminal remount, startup rollback, and service disappearance. Existing TUI interaction tests continue to exercise the model selector and question panel through the shared path.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Expose pi-tui objects directly.** This gives plugins maximum freedom but makes private focus, rendering, and teardown state a public compatibility contract. It also cannot arbitrate independently loaded overlays.
|
||||
|
||||
**Put interactive callbacks on command definitions.** Commands are shared by TUI and ACP and remain useful without a terminal. Adding terminal state to `ctx.commands` would couple discovery and dispatch to one presentation implementation.
|
||||
|
||||
**Create a complete TUI slot and action framework at once.** Actions, editor replacement, transcript renderers, status regions, and completion providers have different composition and conflict rules. Shipping them behind one broad API would freeze those rules before a concrete consumer proves them.
|
||||
|
||||
**Persist open overlays in session events.** Modal presentation is not model-visible session state, and arbitrary component state is not replayable. The plugin that owns durable data records that data through its domain service and recreates presentation when appropriate.
|
||||
|
||||
## Consequences
|
||||
|
||||
Interactive plugins gain a small stable front door with deterministic focus and Cordis-owned cleanup, while the TUI keeps authority over terminal lifecycle and pi-tui internals. Built-in dialogs and extensions cannot overlap or strand focus.
|
||||
|
||||
The API deliberately covers modal overlays only. Human command registration remains on `ctx.commands`; actions, slots, editor replacement, event renderers, and completion providers require separate contracts when real consumers establish their ordering and ownership semantics. FIFO serialization also means one stalled overlay blocks later modal work until its owner closes, aborts, or unloads it.
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Agent Note: 由 effect 持有的 TUI 交互扩展
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-tui-interactive-extension-service.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Cordis 插件可以通过 `ctx.commands` 注册用户命令,但需要终端交互的命令没有受支持的呈现边界。它只能保持非交互,或者捕获 TUI 私有的 pi-tui 树、焦点状态、渲染器和关闭生命周期。此类耦合会使扩展依赖某个入口的内部实现,让各自独立开发的浮层争抢焦点,并导致插件卸载时无法可靠移除排队中或已显示的 UI。
|
||||
|
||||
## 决策
|
||||
|
||||
挂载的 `@deepseek-ai/dsh-tui` 在终端成功启动后提供 `ctx.tui`。该服务只属于挂载时绑定的终端与 agent(智能体),在终端拆卸前消失,并使注入它的插件随着提供方的可用与否卸载和重新加载。其他入口不会模拟该服务。
|
||||
|
||||
`ctx.tui.openOverlay()` 是第一个也是唯一一个交互扩展原语。它接受组件工厂、受限的布局选项,以及可选的中止信号。工厂收到一个冻结的 host,其中包含当前视口、语义化主题函数、显示文本转义、重绘、关闭和生命周期信号。它不会收到 pi-tui `TUI`、浮层句柄、编辑器、transcript(文本记录)树、焦点控制器或终端对象。
|
||||
|
||||
一个私有浮层管理器按 FIFO 顺序串行处理内置请求和插件请求。模型选择器与 `ctx.userInteraction` 问题面板使用同一个管理器,因此所有模态交互只有一个焦点所有者。关闭活动浮层时,系统会先恢复 pi-tui 之前的焦点,再激活下一项请求。浮层状态是进程本地的呈现状态:它既不会追加到会话日志,也不会在恢复期间重建。
|
||||
|
||||
服务方法通过 Cordis 的可追踪服务代理运行。它在接纳请求前,向调用方插件的 fiber 注册一个 effect;因此,调用方执行 dispose(资源释放)时会移除排队中的请求或关闭活动浮层,并等待同一个结果完成结算。TUI 关闭时会先拒绝新请求,再 dispose 服务 fiber,让依赖插件及其 effect 完全静止,然后结算其余内置工作,最后才排空并停止终端。
|
||||
|
||||
组件构造、渲染、输入与失效处理均在异常边界内运行。任何失败都会以 `error` 结果关闭对应请求、在终端中报告一条可见错误,并让队列继续处理。组件属于受信任的包代码:其渲染行可以包含 ANSI 样式,但加入不受信任的文本前必须调用 `host.display()`。
|
||||
|
||||
## 验证
|
||||
|
||||
管理器测试固定了 FIFO 准入、取消、重复关闭、关闭结果、受保护回调、host 能力和逐文件覆盖率。Cordis 生命周期测试固定了调用方所有权、提供方消失与恢复、卸载期间的拒绝,以及清理达到完全静止。模拟终端集成测试覆盖插件浮层与内置问题的协作、编辑器输入焦点恢复、终端重新挂载、启动回滚和服务消失。既有 TUI 交互测试继续通过共享路径覆盖模型选择器与问题面板。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**直接暴露 pi-tui 对象。** 这会赋予插件最大的自由度,却会把私有的焦点、渲染与拆卸状态变成公开兼容性契约,也无法在独立加载的浮层之间进行仲裁。
|
||||
|
||||
**在命令定义中加入交互回调。** 命令由 TUI 与 ACP 共享,即使没有终端也仍然有用。向 `ctx.commands` 添加终端状态,会让发现与分派流程耦合到某一种呈现实现。
|
||||
|
||||
**一次性建立完整的 TUI slot 与 action 框架。** action、编辑器替换、transcript 渲染器、状态区域和补全提供方具有不同的组合规则与冲突规则。在具体消费方验证这些规则之前就将其纳入一个宽泛 API,会过早固化这些规则。
|
||||
|
||||
**将打开的浮层持久化为会话事件。** 模态呈现并非模型可见的会话状态,任意组件状态也无法回放。拥有持久数据的插件应通过自身的领域服务记录这些数据,并在适当时重新创建呈现。
|
||||
|
||||
## 后果
|
||||
|
||||
交互式插件获得一个小而稳定的入口,具备确定性的焦点管理和由 Cordis 持有的清理机制;TUI 则继续掌控终端生命周期和 pi-tui 内部实现。内置对话框与扩展无法重叠,也不会遗留失去归属的焦点。
|
||||
|
||||
该 API 有意只覆盖模态浮层。用户命令仍然在 `ctx.commands` 上注册;action、slot、编辑器替换、事件渲染器和补全提供方需要另行设计契约,等待实际消费方确定其顺序与所有权语义。FIFO 串行处理也意味着,一个停滞的浮层会阻塞后续模态工作,直至其所有者关闭、中止或卸载该浮层。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-toolview-dissolution.md: a420c5945d0272cf8087d5f623e9c383c286d7c2
|
||||
2026-07-23-toolview-dissolution.zh.md: 47c1f392f5f7ddbf4e6c686b2574faa7987e6126
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Toolview dissolution — tool rows are per-view keyed slots
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-toolview-dissolution.zh.md)
|
||||
|
||||
> Scope: why the standalone tool ring (ToolViewRegistry/ctx.toolviews/outlet) was retired and what replaced it. The [web client architecture note](2026-07-19-gui-web-client-architecture.md) carries the shipped-state narrative this decision produced; the [slot system standard](2026-07-22-slot-type-chain-implementation.md) owns the registration model everything now runs on.
|
||||
|
||||
## Problem
|
||||
|
||||
After the view ring dissolved into the slot system, the client kept exactly one parallel registration model: the tool ring — a named registry (`ctx.toolviews`) with its own register grammar, its own resolve semantics (scoped-beats-global predicate dispatch), its own subscribe/version pair, its own inject cache, and its own render outlet with a private error boundary. Every one of those was a second implementation of something the slot machinery already owned, and every future capability (a store seat for row drafts, i18n injection, cross-bundle identity) would have had to be built twice or drift. The ring's one honest justification was that tool names are a runtime-open set while `SlotMap` is a closed declaration table — a registry keyed by arbitrary strings seemed structurally necessary.
|
||||
|
||||
## Decision
|
||||
|
||||
The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively.
|
||||
|
||||
Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations.
|
||||
|
||||
Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option.
|
||||
|
||||
## Accepted semantic changes
|
||||
|
||||
Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance is per-view registration — a row must adapt to each view's layout anyway, so one registration per view is the correct coupling, and reuse is the same component in two register calls. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch moved from registry predicates into the component. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the standalone registry (the original shape).** Rejected: each of its multi-dimensional dispatch axes has a more correct home — the view dimension belongs to each view's own declared child slot (declaring is claiming, so specialization ownership lands right), and the session dimension belongs inside the component, which already holds the standard kit. What remained after both moves was a second copy of slot machinery with no distinguishing capability.
|
||||
|
||||
**Promote `renderToolView` into the standard kit and move the registry into the runtime package.** Rejected: "tool row" is a conversation-domain concept; hoisting it into runtime would leak a domain vocabulary into the framework 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.
|
||||
|
||||
**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) and "don't split preemptively" (today's registrant population is one bash sample), it stays unbuilt; the type sugar ships as the exported `ToolRowProps` alias. Regret clause: if registrants grow to three-to-five or a bulk-registration pattern appears, the facade is ten lines added without disturbing direct registration.
|
||||
|
||||
## Consequences
|
||||
|
||||
The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override), plus one subtlety the load-order seam carries: registrant plugins must declare `inject: ['conversation']` to sequence after the slot declaration, a convention the seam makes correct by construction but does not statically force on third parties.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: toolview 溶解——工具行即 per-view keyed slot
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-toolview-dissolution.md) | 中文
|
||||
|
||||
> 范围:独立工具环(ToolViewRegistry/ctx.toolviews/outlet)为何退役、被什么取代。本决策产出的落地态叙述归 [Web 客户端架构注](2026-07-19-gui-web-client-architecture.md);一切现在所运行其上的注册模型归 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 所有。
|
||||
|
||||
## Problem
|
||||
|
||||
视图环溶解进 slot 体系之后,client 侧恰好还剩一套平行注册模型:工具环——一个具名注册表(`ctx.toolviews`),带自己的 register 文法、自己的 resolve 语义(scoped 压 global 的谓词分发)、自己的 subscribe/version 对、自己的 inject 缓存、自己带私有错误边界的渲染出口。其中每一件都是 slot 机器已经拥有之物的第二份实现,而每一项未来能力(行草稿的 store 席位、i18n 注入、跨 bundle 身份)都将不得不建两遍或漂移。这条环唯一像样的存在理由是:tool 名是运行时开放集,而 `SlotMap` 是封闭声明表——以任意字符串为键的注册表看似结构上必需。
|
||||
|
||||
## Decision
|
||||
|
||||
工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。
|
||||
|
||||
落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions` 读 `parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。
|
||||
|
||||
registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。
|
||||
|
||||
## 接受的语义变化
|
||||
|
||||
四项行为增量是刻意接受而非疏漏。跨视图出场=逐视图注册——行本须适配各视图版式,一视图一注册是正确耦合,复用即同一组件写两次 register。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发从注册表谓词移入组件。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留独立注册表(原形态)。** 拒绝:其多维分发的每一维都有更正确的家——视图维归各视图自己声明的子槽(declaring is claiming,特化面权属自然落对),会话维归已持有标配 kit 的组件内部。两步移完后剩下的只是一份没有任何独有能力的 slot 机器副本。
|
||||
|
||||
**把 `renderToolView` 提进标配 kit、注册表迁入 runtime 包。** 拒绝:「工具行」是 conversation 域概念;上提进 runtime 会把域词汇泄漏进框架层,且依然留着两套注册模型。
|
||||
|
||||
**以订阅 refCount 推导槽声明**(首个注册方订阅时隐式声明槽)。拒绝:隐式耦合加去抖复杂度;记为将来真出现多观看面时的备选。
|
||||
|
||||
**slots.register 之上的薄 `registerToolView` 门面。** 缓建而非拒绝:溶解后该门面只剩编译期三糖(槽名字面量收窄、tool→key 词汇翻译、props 预组合),运行时为零。按「enforce at the operation boundary」(门面不是强制点)与「don't split preemptively」(今天注册方人口只有一个 bash 样例)保持不建;类型糖以导出的 `ToolRowProps` 别名兑现。后悔药条款:注册方长到三五家或出现批量注册模式时,门面十行可补,不扰直注。
|
||||
|
||||
## Consequences
|
||||
|
||||
client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖),外加加载序缝携带的一处微妙:注册方插件须声明 `inject: ['conversation']` 才排在槽声明之后,这条约定由序缝构造保证正确、但不对第三方静态强制。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-code-mode-result-card-completeness.md: 03c14cd780832fa03977dade2c7d14feb0399369
|
||||
2026-07-20-code-mode-result-card-completeness.zh.md: 45047cc5bcb8b74668702302077ff91fd3ff6bdc
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Keep the Code Mode result card complete
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-code-mode-result-card-completeness.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The outer `run_code` tool persisted complete rendered content, but its editor presenter ignored that content and rebuilt the card body from a logs-only `presentationMeta` projection. A result-only run appeared correct because an empty presenter body let ACP and TUI fall back to `tool/result.content`. Once the program emitted a log, the presenter supplied non-empty content, that fallback stopped, and the returned value disappeared from the completed card. A spill policy's final head/tail preview was vulnerable to the same split ownership whenever captured logs made the stale projection non-empty.
|
||||
|
||||
Nested Code calls never owned cards, so producing metadata for the outer call solely to reconstruct one incomplete card also obscured the intended one-card boundary.
|
||||
|
||||
## Decision
|
||||
|
||||
The canonical tool registry pipeline owns the final model-facing outer content. On success, the `run_code` output renderer renders captured logs followed by the return value or the explicit no-output marker. Runtime failures and pre-execution policy denials are normalized into error content by `ToolRegistry` without invoking that renderer. A post-execute block runs after successful rendering and replaces the result with error content; other post-execute policy and spill decisions may replace content before persistence.
|
||||
|
||||
`run_code` omits `presentResult`. The established generic result fallback keeps the pending program title and renders the raw final `tool/result.content`; that durable, replayable, post-policy projection is the card's only result-content source. The host API proxy therefore omits a separate result view instead of serializing the same content in both `event.data.content` and `view.view.content`. The redundant logs-only `presentationMeta` projection remains removed.
|
||||
|
||||
Nested dispatch remains unchanged. Calls marked by `exec.parent` emit bounded `tool/code-dispatch` diagnostics but no `tool/call` or `tool/result` surface cards, so one outer `run_code` invocation still produces exactly one card.
|
||||
|
||||
## Testing
|
||||
|
||||
Tool unit coverage drives logs-only, result-only, logs-plus-result, no-output, spilled-result, and failure outcomes through the canonical registry, then pins the durable content and absence of a result presenter. A host-mux regression uses a call-only presenter to prove the result frame carries raw content exactly once and no view. These cases prove stale metadata cannot replace final content without making the host duplicate that content.
|
||||
|
||||
The keyless ACP and TUI Code Mode snapshots execute one outer program that performs two nested bash calls, logs `captured output`, and returns `CODE_ONE+CODE_TWO`. Both surfaces show one completed outer card containing both lines and no nested cards.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Append the return value to logs metadata.** Rejected because metadata would duplicate the renderer, need a second stable formatting contract for every JSON root, and still miss post-policy content replacement or spill previews.
|
||||
|
||||
**Merge presenter metadata with `result.content`.** Rejected because the rendered content already contains the logs; merging would duplicate them and require brittle deduplication.
|
||||
|
||||
**Forward `result.content` through a generic result presenter.** Rejected because the durable event already carries that content and ACP/TUI already have a generic raw-content fallback. The host mux serializes a tool-owned result view beside the event, so forwarding would duplicate the rendered content in one frame merely to recreate the fallback; the default worker alone admits a 64 MiB variable-payload budget before rendering.
|
||||
|
||||
**Create one card per nested dispatch.** Rejected because intermediate values are intentionally execution-local and never model-facing. Multiple cards would expose an implementation trace instead of the single Code Mode operation the model and user invoked.
|
||||
|
||||
## Consequences
|
||||
|
||||
ACP and TUI display the same complete content the model receives and replay persists, including post-policy spill previews, through their generic result fallback. The host API retains the pending program title without duplicating the raw result in a separate view payload. New `run_code` results no longer carry the optional logs metadata, but this requires no session-format bump: existing records remain valid because presentation reads their durable rendered content.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 保证 Code Mode 结果卡片内容完整
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-code-mode-result-card-completeness.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
外层 `run_code` 工具会持久化完整的渲染内容,但编辑器的卡片展示逻辑忽略了这些内容,转而根据仅含日志的 `presentationMeta` 投影重新构建卡片正文。仅有结果的运行看似正确,是因为展示逻辑未提供正文时,ACP 和 TUI 会回退到 `tool/result.content`。只要程序输出一条日志,展示逻辑就会提供非空内容,回退随即停止,返回值便会从完成态卡片中消失。当已捕获的日志使陈旧投影变为非空时,输出落盘策略最终生成的头尾预览也会受到同一职责拆分的影响。
|
||||
|
||||
嵌套 Code 调用从不生成自己的卡片。因此,仅仅为了重建这一张不完整卡片而给外层调用生成元数据,还掩盖了每次外层调用只生成一张卡片的预期边界。
|
||||
|
||||
## 决策
|
||||
|
||||
规范的工具注册表流水线负责最终面向模型的外层内容。成功时,`run_code` 输出渲染器先渲染已捕获的日志,然后渲染返回值或显式的无输出标记。运行时失败和执行前策略拒绝由 `ToolRegistry` 归一化为错误内容,过程中不会调用该渲染器。Post-execute 阻断发生在成功渲染之后,并把结果替换为错误内容;其他 post-execute 策略与输出落盘决策可以在持久化之前替换内容。
|
||||
|
||||
`run_code` 不提供 `presentResult`。既有的通用结果回退机制会保留待完成的程序标题,并渲染原始的最终 `tool/result.content`;这一持久、可回放且经过 post-policy 处理的投影是卡片中结果内容的唯一来源。宿主 API 代理因此不提供单独的结果视图,而不会在 `event.data.content` 与 `view.view.content` 中重复序列化同一内容。冗余的仅含日志的 `presentationMeta` 投影继续保持移除状态。
|
||||
|
||||
嵌套分发保持不变。带有 `exec.parent` 标记的调用会发出有界的 `tool/code-dispatch` 诊断,但不会生成与 `tool/call` 或 `tool/result` 对应的界面卡片,因此一次外层 `run_code` 调用仍然只会生成一张卡片。
|
||||
|
||||
## 测试
|
||||
|
||||
工具单元测试通过规范注册表覆盖仅有日志、仅有结果、日志与结果并存、无输出、结果落盘和失败的结果,然后固定持久内容以及结果展示器不存在这一事实。宿主 mux 回归测试使用仅有调用的展示器,证明结果帧恰好携带一次原始内容,且不含视图。这些案例证明陈旧元数据无法替换最终内容,同时不会让宿主重复该内容。
|
||||
|
||||
无密钥的 ACP 与 TUI Code Mode 快照会执行一个外层程序:程序进行两次嵌套 bash 调用,记录 `captured output`,并返回 `CODE_ONE+CODE_TWO`。两个界面都只显示一张完成态外层卡片,其中包含这两行内容,且没有嵌套卡片。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**把返回值追加到日志元数据:**不予采纳。元数据会与渲染器重复,并且需要为每一种 JSON 根另行维护稳定的格式化契约;post-policy 内容替换或输出落盘预览仍然会被遗漏。
|
||||
|
||||
**把展示元数据与 `result.content` 合并:**不予采纳。渲染内容已经包含日志;合并会造成重复,还需要依赖脆弱的去重逻辑。
|
||||
|
||||
**通过通用结果展示器转发 `result.content`:**不予采纳。持久事件已经携带该内容,ACP 和 TUI 也已有通用的原始内容回退机制。宿主 mux 会在事件旁序列化工具拥有的结果视图,因此转发仅仅是为了重建该回退机制,却会在一个帧中重复渲染内容;仅默认 worker 在渲染前允许 64 MiB 的可变载荷预算。
|
||||
|
||||
**为每次嵌套分发创建一张卡片:**不予采纳。中间值有意只存在于执行期间,永远不面向模型。多张卡片会暴露实现轨迹,而不是模型与用户调用的单次 Code Mode 操作。
|
||||
|
||||
## 影响
|
||||
|
||||
ACP 和 TUI 通过通用结果回退机制显示与模型接收及回放持久化相同的完整内容,其中包括 post-policy 输出落盘预览。宿主 API 保留待完成的程序标题,同时不在单独的视图负载中重复原始结果。新的 `run_code` 结果不再携带可选的日志元数据,但无需提升会话格式版本:现有记录仍然有效,因为展示逻辑会读取其中持久化的渲染内容。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40
|
||||
2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: A config hot-reload must not kill or degrade a live app
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-config-hot-reload-resilience.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones.
|
||||
|
||||
## Decision
|
||||
|
||||
Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers:
|
||||
|
||||
- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down.
|
||||
- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged".
|
||||
- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay.
|
||||
|
||||
Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include.
|
||||
|
||||
**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place.
|
||||
|
||||
**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file).
|
||||
|
||||
## Consequences
|
||||
|
||||
- A bad `cordis.yml` edit now logs `ignoring config reload at <file>` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred.
|
||||
- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base.
|
||||
- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync.
|
||||
- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree).
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: 配置热重载不得杀死或降级正在运行的应用
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-config-hot-reload-resilience.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出,HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树(Code Mode、个人 overlay)做任何热重载,都会悄悄把打过补丁的配置项回退、并把插入的配置项移除。
|
||||
|
||||
## Decision
|
||||
|
||||
加固 vendor 的 `@cordisjs/plugin-include`(在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方:
|
||||
|
||||
- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出。
|
||||
- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。
|
||||
- `refresh()` 与 `internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。
|
||||
|
||||
启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`(或「config file not found」);存在但无效的文件现在会以真实的解析错误失败,而不是被误标为文件缺失、或被 `initial` 静默覆盖。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 HMR 监听回调里捕获,而不是在 `refresh()` 里。** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷。
|
||||
|
||||
**在 `installFailLoud` 里过滤配置文件相关的 rejection。** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃。
|
||||
|
||||
**用 PTY e2e 证明 TUI 能在错误编辑后存活。** 否决其作为主要门禁:PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件)。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at <file>`,agent 继续运行在上一份完好的配置树上;下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓。
|
||||
- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置。
|
||||
- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用。
|
||||
- 已知缺口,不在本次范围内:HMR 监听器只处理 chokidar 的 `change` 事件,因此通过重命名替换文件的编辑方式(BSD `sed -i`、`git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI(未修改的代码树上即已如此)。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection,并保留之前的配置项配置;随后的合法编辑正常生效;overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-jsonl-storage-identity.md: 1ada16791f411a54fbcf9271c7d7963223bbe683
|
||||
2026-07-20-jsonl-storage-identity.zh.md: 8027c51dbf6c7d01463b7851d859a40890bf03e1
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Bind JSONL session identity before mutation
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-jsonl-storage-identity.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
JSONL lookup selects a physical log from the requested session id across cwd buckets, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The bucket scan also needs a defined result when the same encoded id exists in more than one bucket. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id.
|
||||
|
||||
## Decision
|
||||
|
||||
`loadStored(id)` is the coordinator's single stored-prefix lookup. The JSONL backend scans every cwd bucket, requires at most one matching encoded filename, parses that file, then validates both `header.id === id` and `selectedPath === logPath(root, header.cwd, header.id)` before returning metadata. `list()` applies the same path validation and rejects duplicate ids across buckets.
|
||||
|
||||
The coordinator independently asserts the returned id and compares the stored cwd with a live session's cwd before repair, state publication, or suffix persistence. It keeps a detached copy of validated metadata; JSONL append and repair derive their path from that copy. The `PersistenceBackend<TornMarker>` interface therefore needs neither a scope-specific live lookup nor a storage-locator type.
|
||||
|
||||
An existing configured JSONL root must be a readable directory when the plugin loads. An absent root remains valid and is created on first materialization. The backend supports one live writer per session; another backend instance or process must not mutate that session until the owner finishes disposal and all writes stop.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without changing the project-grouped cwd layout or its consumers.
|
||||
|
||||
**Carry an opaque storage locator through the coordinator.** A locator binds JSONL mutations directly to a selected path, but JSONL can reproduce that path from metadata it has already validated. Adding another generic and argument to SQLite, test backends, append, and repair makes every implementation carry a concept only the file backend needs.
|
||||
|
||||
**Coordinate multiple live writers.** A dedicated coordination service, process-global registry, or cross-process lock would define a new deployment topology rather than repair identity validation. The supported topology has one live writer; no-overwrite hard-link publication still arbitrates an initial same-id creation race.
|
||||
|
||||
## Consequences
|
||||
|
||||
Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. The cwd-bucket format stays unchanged and needs no migration. Lookup remains proportional to the number of buckets, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, cwd collision handling, and load-time root validation.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 在变更前绑定 JSONL 会话身份
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-jsonl-storage-identity.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd,并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个分桶目录中时,分桶扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。
|
||||
|
||||
## 决策
|
||||
|
||||
`loadStored(id)` 是协调器唯一的已存前缀查找操作。JSONL 后端扫描所有 cwd 分桶目录,要求匹配编码文件名的日志至多有一个,解析该文件,然后在返回元数据前同时验证 `header.id === id` 和 `selectedPath === logPath(root, header.cwd, header.id)`。`list()` 执行相同的路径验证,并拒绝跨分桶目录重复的 id。
|
||||
|
||||
协调器会独立断言返回的 id,并在修复、发布状态或持久化后缀之前比较已存 cwd 和活动会话的 cwd。协调器保留一份已验证元数据的独立副本;JSONL 的追加和修复操作根据该副本派生路径。因此,`PersistenceBackend<TornMarker>` 接口既不需要限定范围的活动会话查找,也不需要存储定位器类型。
|
||||
|
||||
如果配置的 JSONL 根目录已存在,插件加载时该路径必须是可读目录。根目录不存在仍然是有效配置,首次物化时会创建该目录。后端对每个会话只支持一个活动写入方;在所有者完成资源释放且所有写入停止之前,另一个后端实例或进程不得变更该会话。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需改变按项目分组的 cwd 布局及其消费方,也能消除身份缺陷。
|
||||
|
||||
**通过协调器传递不透明存储定位器。** 定位器可以将 JSONL 变更直接绑定到选定路径,但 JSONL 可以根据已经验证的元数据重新得到该路径。为 SQLite、测试后端、追加和修复操作增加一个泛型和参数,会让每个实现都承担只有文件后端需要的概念。
|
||||
|
||||
**协调多个活动写入方。** 专用协调服务、进程级全局注册表或跨进程锁会定义新的部署拓扑,而不是修复身份验证。受支持的拓扑只有一个活动写入方;禁止覆盖的硬链接发布仍会裁决初始的同 id 创建竞争。
|
||||
|
||||
## 后果
|
||||
|
||||
JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。cwd 分桶格式保持不变,无需迁移。查找开销仍与分桶目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝、cwd 冲突处理以及加载时的根目录验证。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-collapsed-sidebar-control-rail.md: e959eef37a9e9c0fea79b82ff970daddd9257609
|
||||
2026-07-22-collapsed-sidebar-control-rail.zh.md: 7f6d6529a8aa4a655a1d3292e7f41bfb822f05a3
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: A collapsed sidebar retains its control rail
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-collapsed-sidebar-control-rail.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The sidebar close action persisted a zero width preference, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout.
|
||||
|
||||
## Decision
|
||||
|
||||
The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 56px: a 24px icon column between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched.
|
||||
|
||||
`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. Collapse and expand animate: the frame transitions `grid-template-columns` (and the remaining handle its `left`) on the deepsuite sider curve — `--ds-ease-in-out` over `--ds-transition-duration-slow`, both supplied by ui-theme's base sheet; transitions pause during drags and under `prefers-reduced-motion`.
|
||||
|
||||
`SidebarRoot` reads the owner `collapsed` prop and morphs in place rather than swapping renders: the four control rows persist into the rail — expand toggle, new session, new workspace, search, in the same top-down order as their expanded rows — animating their geometry (heights, paddings, margins, capsule borders) on the same curve, each aligned with its expanded counterpart's behavior (the search icon expands the sidebar and focuses the search box). Wide-only content (brand, labels, input, session tree) cross-fades out over 200ms, stays mounted while the collapse animates, and unmounts once the 300ms settle passes — dropping the sessions subscription and leaving the rendered and accessibility trees. The search query lives with the root and survives the round trip.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Render an expand button over the center column** — rejected because it recovers only the toggle, not the persistent settings area, and splits sidebar chrome across two package owners.
|
||||
- **Keep a zero-width grid track and let the rail overflow it** — rejected because the rail would overlap the center column and leave hit testing and responsive geometry disconnected from the grid.
|
||||
- **Keep the complete sidebar tree mounted and hide it with clipping** — rejected because hidden controls remain in the semantic tree and continue subscribing and rendering even though only two controls belong in the collapsed state.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A collapsed sidebar reserves 56px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior.
|
||||
- The settings entry remains visible but retains its existing placeholder behavior; this change does not introduce an account or settings screen.
|
||||
- Layout solver tests pin the compact width, sidebar component tests pin the visible controls, and the keyless real-bundle web smoke test pins collapse and recovery through the assembled client.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 侧边栏折叠后保留控制栏
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-collapsed-sidebar-control-rail.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
侧边栏关闭操作会持久化宽度偏好 `0`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。
|
||||
|
||||
## 决策
|
||||
|
||||
布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。
|
||||
|
||||
`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。折叠与展开带动画:frame 对 `grid-template-columns`(以及余下手柄的 `left`)应用 deepsuite 侧栏曲线过渡——`--ds-ease-in-out` 配 `--ds-transition-duration-slow`,两个变量由 ui-theme 的 base 表提供;拖拽期间和 `prefers-reduced-motion` 下过渡暂停。
|
||||
|
||||
`SidebarRoot` 读取 owner 的 `collapsed` 属性,原地 morph 而非切换渲染:四个控件行持续存在并演变为控制栏——展开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致——几何(行高、内边距、外边距、胶囊边框)走同一条曲线动画,行为与展开态对应控件对齐(搜索图标会展开侧边栏并聚焦搜索框)。宽态专属内容(品牌标识、文字标签、输入框、会话树)以 200ms 交叉淡出,折叠动画期间保持挂载,300ms settle 后卸载——随之退订会话列表并离开渲染树与可访问性树。搜索关键词由根组件持有,折叠往返后保留。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **在中心列上方渲染展开按钮**:不予采纳,因为这只能恢复开关,无法保留常驻设置区域,同时还会让侧边栏 UI 由两个包(package)分别持有。
|
||||
- **保留宽度为零的网格轨道,让控制栏溢出显示**:不予采纳,因为控制栏会与中心列重叠,还会使命中测试和响应式几何关系脱离网格布局。
|
||||
- **保持完整侧边栏树挂载,并通过裁切将其隐藏**:不予采纳,因为隐藏控件仍留在语义树中,而且会继续订阅和渲染,尽管折叠状态下只需要两个控件。
|
||||
|
||||
## 后果
|
||||
|
||||
- 折叠的侧边栏占用 56px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。
|
||||
- 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。
|
||||
- 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 Web 冒烟测试则通过组装后的客户端固定折叠与恢复行为。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-demo-web-builds-client-bundles.md: a7d21987d4544246fd3c53864cedfc86279e9440
|
||||
2026-07-23-demo-web-builds-client-bundles.zh.md: f10184642b0c7869378802d3040ebf4dbe67d4e0
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: demo:web builds the client plugin bundles
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-demo-web-builds-client-bundles.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh web` serves each web-client plugin's bundle from `GET /plugins/<id>/client.js`, resolving the path from the package's `exports["./client"]` (`lib/client.js`). Those bundles are produced only by the root `pnpm run build` (`tsc -b` then the per-package `tsdown.client.ts` configs); the Vite `build:web` step builds the frontend shell alone. `demo:web` and the README's Web UI instructions ran only `build:web`, so on a checkout without a prior full build every plugin bundle 404s, the client loader marks every plugin failed, and the boot screen shows "Failed to load plugins". The frontend shell built fine, hiding the missing artifact behind a runtime browser failure.
|
||||
|
||||
## Decision
|
||||
|
||||
`demo:web` runs `npm run build` before `npm run build:web`, so the plugin `lib/client.js` bundles exist before `dsh web` serves them. The README's Web UI section runs `pnpm run build && pnpm run build:web` for the installed `~/.dsh/source` checkout, which the installer never builds.
|
||||
|
||||
## Verification
|
||||
|
||||
After the full build, all eight `/plugins/<id>/client.js` endpoints return 200 and a headless Chromium load of `http://127.0.0.1:3080` renders the shell with no "Failed to load plugins" state.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Build the bundles inside `dsh web` at startup.** The app runs from source via tsx and owns no build step; folding an artifact build into the server boot crosses the source/artifact separation and slows every launch.
|
||||
|
||||
**Widen the tsdown root config to emit client bundles from `pnpm run build:web`.** `build:web` is the Vite frontend build; the client bundles are a separate tsdown pass over `lib/types`. Merging the two conflates the shell build with the package build and still leaves the root `build` as the only producer.
|
||||
|
||||
## Consequences
|
||||
|
||||
`demo:web` now pays the full `tsc -b && tsdown` cost on every invocation instead of only the Vite build. That is the price of a runnable web demo from a clean tree; a caller who already built can invoke `dsh web` directly.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: demo:web 构建客户端插件的打包产物
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-demo-web-builds-client-bundles.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh web` 通过 `GET /plugins/<id>/client.js` 提供每个 web 客户端插件的打包产物,其路径由包的 `exports["./client"]`(`lib/client.js`)解析得到。这些打包产物只由根目录的 `pnpm run build`(先 `tsc -b`,再执行各包的 `tsdown.client.ts` 配置)生成;Vite 的 `build:web` 步骤只构建前端外壳。`demo:web` 与 README 的 Web UI 说明只运行了 `build:web`,因此在未预先完整构建的检出上,每个插件的打包产物都返回 404,客户端 loader 将所有插件标记为失败,启动界面显示 "Failed to load plugins"。前端外壳能正常构建,把缺失的产物掩藏在浏览器运行时的失败背后。
|
||||
|
||||
## Decision
|
||||
|
||||
`demo:web` 在 `npm run build:web` 之前先运行 `npm run build`,使插件的 `lib/client.js` 打包产物在 `dsh web` 提供它们之前已经存在。README 的 Web UI 小节针对已安装的 `~/.dsh/source` 检出运行 `pnpm run build && pnpm run build:web`,因为安装器从不构建它。
|
||||
|
||||
## Verification
|
||||
|
||||
完整构建后,全部八个 `/plugins/<id>/client.js` 端点均返回 200,无头 Chromium 加载 `http://127.0.0.1:3080` 能渲染出外壳,不再出现 "Failed to load plugins" 状态。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 `dsh web` 启动时构建打包产物。** 该应用通过 tsx 从源码运行,本身没有构建步骤;把产物构建塞进服务器启动流程会越过源码与产物的分离,并拖慢每次启动。
|
||||
|
||||
**扩大 tsdown 根配置,使 `pnpm run build:web` 也产出客户端打包产物。** `build:web` 是 Vite 前端构建;客户端打包产物是对 `lib/types` 的另一趟独立 tsdown 处理。把两者合并会混淆外壳构建与包构建,而且根目录的 `build` 仍是唯一的产出者。
|
||||
|
||||
## Consequences
|
||||
|
||||
`demo:web` 现在每次调用都要付出完整的 `tsc -b && tsdown` 代价,而不再只是 Vite 构建。这是从干净的代码树运行 web 演示所要付出的代价;已经完成构建的调用方可以直接调用 `dsh web`。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-thinking-row-disclosure-target.md: f698c3cb0b73bf5c65b5d4b5b3f29de3080e0af6
|
||||
2026-07-23-thinking-row-disclosure-target.zh.md: 0fba5c1d8f7beec7300dcd51e118a08d57d0e74f
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Thinking rows use one disclosure target
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-thinking-row-disclosure-target.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A collapsed reasoning entry presents `Think` and its one-line reasoning summary as one visual row, but an icon-only disclosure control leaves both visible labels inert. Applying title expansion to every tool row would instead break the generic tool-row contract, where the row opens details and only the leading control expands arguments.
|
||||
|
||||
## Decision
|
||||
|
||||
`ToolRow` exposes the opt-in `expandOnRowClick` policy. `ThinkRow` enables it so the title and reasoning summary form one accessible disclosure target; pointer clicks, Enter, and Space toggle the same component-local expanded state. Tool rows that do not opt in retain row-to-details selection and leading-control argument expansion.
|
||||
|
||||
## Verification
|
||||
|
||||
The component spec pins both Think click targets and the unchanged generic tool-row handoff. The keyless browser fixture loads the real sidebar and conversation bundles, opens an authored reasoning session, clicks the summary and title, and checks the disclosure state and expanded body.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Expand every tool row from its title.** Generic tool rows use row clicks for details selection, so sharing this behavior would conflate two controls.
|
||||
|
||||
**Keep icon-only disclosure.** The smallest hit target remains disconnected from the labels that describe the hidden content.
|
||||
|
||||
**Render separate title and summary buttons.** Two controls for one expanded state add duplicate focus stops and ambiguous semantics.
|
||||
|
||||
## Consequences
|
||||
|
||||
Thinking rows gain a larger pointer target and keyboard disclosure semantics without changing other tool interactions. The generic row component carries one optional policy because disclosure ownership differs between reasoning and tool calls.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: thinking 行使用单一展开目标
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-thinking-row-disclosure-target.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
折叠的推理(reasoning)条目在同一视觉行中呈现 `Think` 和单行推理摘要,但仅图标可展开会让两个可见标签都无法交互。若让所有工具行均可通过标题展开,又会破坏通用工具行的契约:整行负责打开详情,只有前导控件负责展开参数。
|
||||
|
||||
## 决策
|
||||
|
||||
`ToolRow` 提供显式启用的 `expandOnRowClick` 策略。`ThinkRow` 启用该策略,让标题和推理摘要组成单一且无障碍的展开目标;鼠标点击、Enter 和 Space 都切换同一个组件本地展开状态。未启用该策略的工具行仍由整行完成详情选择,由前导控件展开参数。
|
||||
|
||||
## 验证
|
||||
|
||||
组件测试固定两个 Think 点击目标以及未改变的通用工具行交接行为。无密钥浏览器 fixture(测试前置数据)加载真实的侧边栏与会话 bundle,打开包含推理内容的既定会话,点击摘要与标题,并检查展开状态和展开后的正文。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**让每个工具行都可通过标题展开。** 通用工具行将整行点击用于详情选择,共享这一行为会混淆两个控件。
|
||||
|
||||
**保留仅图标展开。** 最小的点击目标仍与描述隐藏内容的标签脱节。
|
||||
|
||||
**把标题和摘要分别渲染为按钮。** 两个控件共享一个展开状态,会增加重复的焦点停靠点并产生含糊语义。
|
||||
|
||||
## 后果
|
||||
|
||||
thinking 行获得更大的鼠标点击目标和键盘展开语义,同时不改变其他工具交互。通用行组件承担一个可选策略,因为推理与工具调用的展开所有权不同。
|
||||
@@ -20,6 +20,8 @@ Three decisions, each elaborated in its own section below:
|
||||
2. **Code execution is a capability seam** — `packages/code-runtime/` contains the interface package `@deepseek-ai/dsh-code-runtime`, which owns `ctx.codeRuntime` ([capability seams](../architecture/2026-06-13-capability-seams.md); consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is another implementation package, not a redesign.
|
||||
3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority.
|
||||
|
||||
This note owns Code Mode's presentation, composition, isolation, and settlement foundation. The later [typed tool-return Agent Note](2026-07-20-code-mode-typed-tool-returns.md) owns the generated output map, canonical binding values, `ToolCallError`, and the lossless outer-output boundary.
|
||||
|
||||
### The registry owns the mode
|
||||
|
||||
`ToolRegistry` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention.
|
||||
@@ -38,15 +40,15 @@ Three decisions, each elaborated in its own section below:
|
||||
|
||||
Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → optional definition-owned `finalizeContent` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`:
|
||||
|
||||
1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding JSON-normalizes its arguments—rejecting lossy values before dispatch—waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs `tool/code-dispatch`. Successful text becomes a string and non-text blocks become placeholders; tool errors reject the binding promise. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline.
|
||||
1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs `tool/code-dispatch`. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline.
|
||||
2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime.
|
||||
3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured output and presentation metadata. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles.
|
||||
3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured logs and the completion value as canonical output; the registry renders that value into durable `tool/result.content`, which the result card reads directly. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles.
|
||||
|
||||
**Sub-call contexts are deferred through the parent.** Injecting inside `run_code` would break parent call/result adjacency, so `ToolRunContext.deferContext()` collects every sub-result `additionalContexts` entry in dispatch order. The registry carries that array even when the program later throws, and the loop appends each entry only after the outer result and every sibling result in the step. An outer post-execute block discards tool-deferred entries and exposes only contexts explicitly attached by the blocking decision.
|
||||
|
||||
**Concurrency is serialized.** Each run owns a dispatch queue, so even `Promise.all` executes tool calls in submission order. Settlement abandons queued calls that have not started. Parallelism requires per-tool concurrency-safety metadata.
|
||||
|
||||
**Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title = the program text, `rawInput` = the same program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not.
|
||||
**Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so ACP and TUI complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../bug-fix/2026-07-20-code-mode-result-card-completeness.md).
|
||||
|
||||
### Observability: `tool/code-dispatch`
|
||||
|
||||
@@ -57,10 +59,9 @@ Each sub-dispatch appends a log-only `tool/code-dispatch` event containing paren
|
||||
`packages/code-runtime/code-runtime/` — `@deepseek-ai/dsh-code-runtime`, depending only on `cordis`. An abstract `CodeRuntime extends Service` (`super(ctx, 'codeRuntime')`) plus the vocabulary:
|
||||
|
||||
- `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }`
|
||||
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }` — the runtime exposes each namespace as a global object of async functions inside the program; binding arguments and resolutions must be structured-cloneable (a runtime may cross a serialization boundary; ours does).
|
||||
- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }` — program execution outcomes, including exception, timeout, abort, and worker exit, resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary.
|
||||
- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }`
|
||||
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../../docs/defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout.
|
||||
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<CodeJsonValue>>; errorClass?: { name: string; memberNameProperty: string } }` — the runtime exposes each namespace as a global object of async functions inside the program; the optional descriptor asks the runtime to inject a real program-visible rejection class without teaching the seam consumer-specific names. `CodeJsonValue` is this dependency-light seam's structural lossless-JSON type, so binding arguments and resolutions cross the implementation's serialization boundary whole.
|
||||
- `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }` — program execution outcomes resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary.
|
||||
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../../docs/defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout, a lossy completion is not an overflow, and a substrate exit is none of them.
|
||||
- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all).
|
||||
|
||||
Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when Code Mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator.
|
||||
@@ -71,9 +72,9 @@ Requests contain every runtime input; implementations own validated timeout and
|
||||
|
||||
1. **Type-strip host-side** with Node's built-in `stripTypeScriptTypes` (`node:module`; present across the repo's whole engines range, `^22.19.0 || >=24.0.0`, and position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (`enum`, namespaces) — that rejection returns as `error.kind: 'exception'` with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker.
|
||||
2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable.
|
||||
3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals and a capturing `console` shim, so top-level `await` and `return` work and the program's completion value is the run's `value` (structured-cloneable values cross as-is; anything else is replaced by its `util.inspect` rendering, documented).
|
||||
3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals, any consumer-declared rejection classes, and a capturing `console` shim, so top-level `await` and `return` work. Code Mode declares `ToolCallError` with member property `toolName`; the runtime materializes that real constructor without hardcoding tools. A lossless JSON completion crosses exactly; `undefined` remains absence, a lossy value is `invalid-output`, and an oversized outer result is `output-limit` rather than an inspected-string substitute.
|
||||
4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). The worker-side namespace objects are built null-prototype via `defineProperty`, so a binding named `__proto__`, `constructor`, or `toString` is an ordinary own property, not a prototype collision. Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code.
|
||||
5. **Enforce independent budgets.** `computeMs` meters worker busy time, allowing slow awaited tools without excusing a hot loop. `maxWallMs` bounds total elapsed time, including unresolved waits. Expiry, cancellation, and completion terminate the worker. Heap exits and truncation are reported explicitly; compute, wall, heap, log, and return-value caps are validated configuration.
|
||||
5. **Enforce independent budgets.** `computeMs` meters worker busy time, allowing slow awaited tools without excusing a hot loop. `maxWallMs` bounds total elapsed time, including unresolved waits. `maxOutputBytes` bounds only the combined serialized outer logs, completion, or diagnostic; intermediate binding values have no byte cap. Expiry, cancellation, and completion terminate the worker, and heap exits or outer overflow are explicit failures.
|
||||
6. **Dispose to quiescence**: the service's own disposal terminates in-flight workers and *awaits* their exits before resolving, per [defensive patterns](../../../../docs/defensive-patterns.md).
|
||||
|
||||
### Trust posture
|
||||
@@ -90,7 +91,7 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem
|
||||
|
||||
## Testing
|
||||
|
||||
- **Worker runtime:** Real-worker tests cover output and value capture, failure kinds, compute and wall budgets, hostile binding traffic, empty environment, structured-clone fallback, output caps, and disposal to quiescence. A built-package test runs the worker entry under plain Node.
|
||||
- **Worker runtime:** Real-worker tests cover typed binding values and failures, every lossless JSON completion root, invalid and over-limit output, exact combined ledger boundaries, compute and wall budgets, hostile binding traffic, empty environment, and disposal to quiescence. A built-package test runs the worker entry under plain Node.
|
||||
- **Registry integration:** Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, `toolOrder`, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, ordered context deferral across successful and failed programs, outer-block suppression, and HMR cleanup.
|
||||
- **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a Code Mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior.
|
||||
- **Snapshot:** The `code-mode-turn`, `both-mode-turn`, and `code-mode-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards.
|
||||
@@ -123,7 +124,7 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem
|
||||
|
||||
**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Contained by module boundaries inside the package (`ts-types.ts`, `code-mode.ts` beside `schema.ts`/`json-schema.ts`/`presentation.ts`) and by the seam: everything substrate-shaped lives behind `ctx.codeRuntime`.
|
||||
|
||||
**Structured-clone values can exceed JSON.** Tool bindings therefore JSON-normalize arguments before dispatch, ensuring every executed call can be logged. The lower-level runtime keeps its wider port contract, while stricter consumers validate at their boundary. Non-text sub-results become placeholders.
|
||||
**Large lossless JSON values can exhaust memory.** Tool bindings snapshot lossless JSON before dispatch and return canonical JSON resolutions whole. The runtime validates both sides of the worker port and applies no per-binding byte cap; structured-clone cost and process or worker memory are the practical bounds. The combined outer-output ledger for logs, the completion value, and a failure diagnostic is the only byte-capped boundary.
|
||||
|
||||
**Serialized-only sub-dispatch.** `Promise.all` gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs.
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ The first pass rejects Codex-style patch grammars and multi-mode edit APIs. It u
|
||||
|
||||
## Result shape
|
||||
|
||||
The first implementation returns `ContentBlock[]` through the existing `ToolDefinition.execute()` contract. `ctx.fs` returns structured filesystem results and owns file-state recording/refreshing; `tool-fs` formats those results into the model projection.
|
||||
The first implementation formatted `ContentBlock[]` in `execute`. The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) now keeps `ctx.fs` result facts as the tool's validated value and derives the same model text through `output.render`; file-state recording/refreshing remains on `ctx.fs`.
|
||||
|
||||
Default native projections:
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain
|
||||
|
||||
## Decision
|
||||
|
||||
The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. `@deepseek-ai/dsh-agent-core` mounts it for both product front doors and forwards its config. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability.
|
||||
The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability.
|
||||
|
||||
The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope.
|
||||
|
||||
@@ -76,7 +76,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc
|
||||
|
||||
## Consequences
|
||||
|
||||
Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries.
|
||||
Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries.
|
||||
|
||||
Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk).
|
||||
|
||||
|
||||
@@ -24,12 +24,12 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat
|
||||
|
||||
- **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every resolved decision still reaches post-policy; a throwing listener becomes a final normalized failure.
|
||||
- **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids.
|
||||
- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may replace and restore the required `exec.signal` before doing so but cannot remove it, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch.
|
||||
- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContexts`. The returned decision is the supported cross-tool transform channel.
|
||||
- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may replace and restore the required `exec.signal` before doing so but cannot remove it, and receives the already-normalized canonical success/failure result of a thrown or unknown tool; a wrapper-authored success short-circuits dispatch and is re-normalized through the resolved output declaration.
|
||||
- **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, replaces either presentation content or canonical value, or attaches `additionalContexts`. Value replacement revalidates and recomputes presentation; content replacement preserves programmatic value and is not a confidentiality boundary. The returned decision is the supported transform channel.
|
||||
- **`ToolDefinition.finalizeContent`** is an optional synchronous, total, content-only boundary snapshotted with the visible definition at call creation. It runs exactly once after the registry has normalized and losslessly snapshotted the candidate outcome, including pre-, around-, or post-listener failures that bypass later waterfalls and errors discovered while snapshotting another result field. It may replace `content` or preserve it with `undefined`, but cannot rewrite `isError`, structured error identity, contexts, or presentation metadata. This is where a tool enforces its own last-mile content invariant without converting policy failures into weaker block decisions.
|
||||
- **`tools/result`** is the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome.
|
||||
|
||||
Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool; definition-owned final content invariants also cover outer pipeline and candidate-snapshot failures; and a final observer sees exactly what the caller receives and the session log can persist.
|
||||
Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, invalid canonical value, renderer/projector, non-JSON presentation, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool; definition-owned final content invariants also cover outer pipeline and candidate-materialization failures; and a final observer sees the execution-local canonical value beside exactly the presentation fields the session log can persist. The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/projection and durability rules.
|
||||
|
||||
**`TurnEndReason.rejected`** (`dsh-session`): a zero-step turn whose claimed prompt was blocked by `prompt-submit`.
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, awai
|
||||
|
||||
An output schema makes a schema-valid committed capture mandatory for successful child completion. The scoped runtime presents the capture tool and instruction, commits only a successful final outcome—including the enclosing `run_code` outcome for an SDK call—denies later side effects after capture becomes pending, and stops the child without another model step after commit. A validation failure remains a retryable tool error; clean completion without a committed capture settles as an error.
|
||||
|
||||
`StructuredOutputSchema` is the raw enforceable JSON-Schema subset in `dsh-tools` (single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`), and unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [agent-scope runtime-design Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop correctness algorithms.
|
||||
`ObjectJsonSchema` is the object-rooted consumer view of the unified enforceable raw JSON Schema subset in `dsh-tools`; unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [unified JSON-value schema Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md) owns the vocabulary and validation semantics, while the [agent-scope runtime-design Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop algorithms.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -68,7 +68,7 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea
|
||||
- **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool.
|
||||
- **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`.
|
||||
- **Meta embedded in the script as `export const meta = {...}`** (CC's exact format): keeps scripts self-contained and CC scripts drop-in, but obtaining meta requires evaluating model-written text on the host. Even an empty timed vm context cannot bound script-controlled getters when the host reads the resulting object. A JSON parameter removes the scanner, evaluation, and host-spin hole; the cost is that a CC script's meta header must move into the parameter (the body stays drop-in).
|
||||
- **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss.
|
||||
- **`ValueSchemaSpec` as the `outputSchema` wire type**: the author form now has equivalent vocabulary, but a workflow supplies realm-foreign raw JSON Schema data; pretending that runtime data is a trusted author declaration would skip the raw-schema assertion boundary.
|
||||
- **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role.
|
||||
- **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way.
|
||||
- **Provider JSON mode instead of the capture tool:** it guarantees valid JSON, not schema conformance, and its interaction with tool calling is unclear. The capture tool preserves in-turn validation retries. Provider-side strict tool schemas can later narrow the accepted subset without changing this design.
|
||||
|
||||
@@ -117,7 +117,7 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s
|
||||
|
||||
- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes.
|
||||
- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip.
|
||||
- **With-key:** drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip.
|
||||
- **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip.
|
||||
- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins a successful workspace-write mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent, and policy scenarios switch explicitly.
|
||||
|
||||
## Deferred phases
|
||||
|
||||
@@ -30,9 +30,9 @@ Mount code runs as an async-function body in a fresh vm realm. Its documented su
|
||||
|
||||
Sandbox globals are deliberately small: a tagged write-through `console` (`[cordis:<id>] …` on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the `harness.defineTool` / `harness.registerTool` registration pair, the encoding primitives fresh vm contexts lack (`btoa`/`atob` as host closures over `Buffer` — a sanctioned exception, `Buffer` itself is never exposed — plus `TextEncoder`/`TextDecoder`), and callable traps over the withheld Node APIs (`require`, `setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`, `fetch`) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; `process` and `Buffer` stay `undefined` so a `typeof` feature probe stays inert rather than detonating a throwing accessor.
|
||||
|
||||
Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` normalizes results into host-realm JSON and validates the `ToolExecuteReturn` shape before logging. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRegistry.execute` by calling a definition directly.
|
||||
Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` rebuilds the output schema/projectors in the host realm, snapshots the body value as host-owned JSON, and lets the registry enforce the [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) before observation. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRegistry.execute` by calling a definition directly.
|
||||
|
||||
The boundary normalizes unambiguous JSON-Schema forms into `SchemaSpec`, including object wrappers, `integer`, and optional fields. Invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals.
|
||||
The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec`, preserving `integer`, raw object openness, and required arrays. Direct DSL object nodes must declare `additionalProperties`; invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals.
|
||||
|
||||
### The dynamic group and mount lifecycle
|
||||
|
||||
@@ -60,7 +60,7 @@ Model-visible ⟺ logged holds with no new session event type: a mount or unmoun
|
||||
|
||||
| Dimension | Structured per-capability tools | Single `cordis_mount` |
|
||||
|---|---|---|
|
||||
| Schema correctness | `parameters` is still a model-written JSON object needing SchemaSpec validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors |
|
||||
| Schema correctness | `parameters` is still model-written JSON needing unified-schema validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors |
|
||||
| The code field | An `execute` body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged | One sandbox, one normalization path, one guarded registration |
|
||||
| Capability coverage | Tools only; listeners, services, `inject` relations each need another structured tool — a surface that grows without bound | One vocabulary (a cordis plugin) covers every effect, present and future |
|
||||
| Cross-mount composition | Not expressible in a tool-registration payload | Native `provide`/`inject`, ordinary cordis semantics |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-code-mode-typed-tool-returns.md: 29f139a7e965de3a374d195ecc205210e6ae7e93
|
||||
2026-07-20-code-mode-typed-tool-returns.zh.md: 431c0b1717c6783771255ce8291c241f8f92c30b
|
||||
@@ -0,0 +1,112 @@
|
||||
# Agent Note: Typed tool returns in Code Mode
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-code-mode-typed-tool-returns.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Code Mode originally projected each nested tool result back from `ContentBlock[]` into one string. That preserved the human-readable Native surface but erased the canonical result the tool had already produced: programs had to scrape task ids and dynamic mount ids from prose, structured search and workflow results lost their shape, and non-text blocks became placeholders. The generated SDK could describe arguments but could only promise `Promise<string>` regardless of the tool's real output.
|
||||
|
||||
The runtime also treated binding values and the final program value as presentation data. Separate log and completion caps could replace an oversized or non-cloneable completion with inspected text even though intermediate values do not enter model context. That made programmatic composition lossy and confused the memory boundary with the prompt boundary.
|
||||
|
||||
The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) establishes one validated execution-time value and a separate Native renderer. Code Mode should consume that value directly, preserve it across the worker boundary, and bound only the final output the program deliberately returns to the model.
|
||||
|
||||
## Decision
|
||||
|
||||
Code Mode is a typed projection of the visible tool registry. Each successful binding resolves to the final canonical `JsonValue` after post-execute policy, while a failed binding rejects with a real `ToolCallError`. Intermediate values remain inside the run and cross the worker boundary whole. Only the outer `run_code` logs, completion value, or failure diagnostic enter the configurable output ledger and the model-facing spill pipeline.
|
||||
|
||||
This note owns the return and failure contract layered on the original [Code Mode foundation](2026-06-15-code-mode.md). The unified schema vocabulary is owned by the [JSON-value schema DSL note](../architecture/2026-07-20-unified-json-value-schema-dsl.md), and Native rendering and policy projection remain owned by the canonical-output note.
|
||||
|
||||
### Generated SDK
|
||||
|
||||
At each prompt assembly the registry projects every visible tool's parameter schema and detached canonical output schema into one deterministic declaration:
|
||||
|
||||
```ts ignore-check
|
||||
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
interface ToolArgsMap {
|
||||
// one exact inferred entry per visible tool
|
||||
}
|
||||
|
||||
interface ToolOutputMap {
|
||||
// one exact inferred entry per visible tool
|
||||
}
|
||||
|
||||
type ToolName = keyof ToolOutputMap
|
||||
|
||||
declare class ToolCallError extends Error {
|
||||
readonly name: 'ToolCallError'
|
||||
readonly toolName: ToolName
|
||||
}
|
||||
|
||||
declare const tools: {
|
||||
[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>
|
||||
}
|
||||
```
|
||||
|
||||
`jsonSchemaToTs()` covers every supported unified-schema node: object, array, string, number, integer, boolean, null, unconstrained JSON, scalar `enum` and `const`, and `oneOf`. Unsupported raw constructs degrade to `unknown` during prompt generation rather than breaking assembly. Tool names retain their exact keys, including names that require quoted access.
|
||||
|
||||
### Binding values and failures
|
||||
|
||||
Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program.
|
||||
|
||||
Code Mode declares its rejection capability on the runtime request as `{ name: "ToolCallError", memberNameProperty: "toolName" }`. The runtime seam treats those names as data: the worker materializes and injects the actual constructor used for `tools` binding failures, so `error instanceof ToolCallError` works without making a generic runtime know about tools. The worker constructs failures and defines their public fields through module-captured error and property-definition intrinsics plus null-prototype descriptors, so model mutations cannot replace the promised rejection with a worker failure. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification.
|
||||
|
||||
Binding arguments and resolutions are revalidated as lossless JSON on both sides of the hostile worker protocol and have no byte cap. Before crossing through structured clone, each detached value is encoded as a flat pre-order token stream whose transport nesting is bounded; the receiver rebuilds it iteratively. Valid application nesting therefore has neither a JavaScript call-stack depth cap nor a platform-specific nested structured-clone limit. At module initialization the worker captures its own realm's `Array.prototype` and `Object.prototype` identities, the native function-source intrinsic used only to recognize foreign-realm plain-container prototypes, and every structural and metering intrinsic used by the JSON boundary. Property writes use null-prototype descriptors, while private array and set operations invoke captured methods without consulting mutable global or prototype slots. Model code can therefore replace helpers such as `Object.keys`, `Array.isArray`, collection methods, string methods, or `Buffer.byteLength`, rewrite intrinsic-prototype constructor slots, or add descriptor-shaped fields to `Object.prototype` without changing validation, wire transport, or byte accounting. The foreign-realm native function-source check still rejects user-authored constructors that imitate `Object` or `Array`. The dependency-light runtime seam names its structural equivalent `CodeJsonValue` so it need not depend on the session-owned canonical type; the generated SDK and tool API use `JsonValue`. Intermediate values are not prompt-truncated, context-spilled, or persisted. This preserves full acquired search, workflow, task, filesystem, and MCP values for programmatic filtering while leaving provider and executor acquisition limits truthful.
|
||||
|
||||
### Outer result and output ledger
|
||||
|
||||
The runtime accepts an exact lossless JSON completion of any root. Returning `undefined` omits the completion; returning `null` is an explicit result. `run_code` exposes the canonical outer value `{ logs: string[], result?: JsonValue }`. Its Native renderer emits logs first, renders a string result raw, and renders every other JSON root with an iterative pretty printer. Total indentation is capped at ten characters and deeper subtrees remain compact, preserving the established shallow text while keeping traversal stack-safe and formatted size linear in the canonical JSON size.
|
||||
|
||||
`WorkerCodeRuntime` replaces the former independent log and value caps with configurable `maxOutputBytes`, defaulting to `67_108_864` bytes. The worker charges captured logs by their exact JSON-string serialization and preflights the detached completion or program exception against the remaining combined budget before posting a terminal message. A giant thrown string or stack therefore crosses the worker port only as the fixed `output-limit` diagnostic. The host repeats the hostile-peer ledger for forged traffic and native pipe writes the worker cannot observe. Fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are deliberately outside this variable-payload ledger. Neither stage materializes an over-limit serialized completion. A result at or below the cap is exact. A completion that cannot survive lossless JSON snapshotting fails as `invalid-output`; a value, diagnostic, or combined outcome over the cap fails as `output-limit` rather than becoming inspected or truncated text.
|
||||
|
||||
Logs stream eagerly so a terminated run can retain output already admitted. Native stdout and stderr writes that bypass the worker's patched stream slots use independent pipes, so terminal settlement continues bounded capture until worker termination completes before materializing the result. When the cap is crossed, the runtime returns an explicit bounded failure with the fitting captured prefix. That outer result then traverses the ordinary `run_code` rendering and spill policy, which may save the captured text and expose its configured head/tail preview. The spill layer cannot recover bytes the runtime rejected beyond the hard cap.
|
||||
|
||||
Compute time, wall time, worker heap, cancellation, and fresh-worker isolation remain independent limits. The outer ledger never charges intermediate bindings, so snapshotting, flat-wire encoding and decoding, structured-clone cost, and available process or worker memory are their practical bounds.
|
||||
|
||||
### Typed handles and lifetime
|
||||
|
||||
Background producers return a typed canonical handle such as `{ kind: 'background', taskId }` while retaining their established Native sentence. A pre-aborted background call remains a failure because successful output promises an id and no task was created. After `ctx.tasks.start()` publishes the id, task-owned cancellation governs the work: settlement or later cancellation of the enclosing `run_code` call does not kill it. A later program can pass the returned id to `task_output`, and `task_kill`, owner disposal, or service teardown owns cancellation. Foreground execution remains coupled to the call signal. The task lifetime contract is owned by the [background task runtime note](../architecture/2026-06-20-generic-long-running-tool-runtime.md).
|
||||
|
||||
Dynamic Cordis mounting follows the same rule: `cordis_mount` returns `{ id, pluginName, state, provides, waitingFor }`, so a program can read `mounted.id`, inspect active or pending state, and pass that id to `cordis_unmount` without parsing the stable Native sentence.
|
||||
|
||||
### Persistence, metadata, and spill
|
||||
|
||||
Nested dispatch keeps the existing bounded `tool/code-dispatch.resultSummary` for diagnostics but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. This is deliberately not a session-format change, so `SESSION_FORMAT_VERSION` remains unchanged and replay cannot recreate intermediate program values.
|
||||
|
||||
The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls because they have no direct result card and their canonical values never enter context. The outer `run_code` call alone produces one card and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so ACP and TUI complete the card through their generic raw-content fallback using durable `tool/result.content`.
|
||||
|
||||
## Testing
|
||||
|
||||
Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, and exotic names. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; model-mutated JSON-boundary globals, prototype methods, constructor slots, and inherited descriptor fields; typed binding failures after those mutations; large uncapped intermediate bindings; nested spill suppression; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution.
|
||||
|
||||
Keyless real-worker integration tests pin the two handle workflows that prose results could not safely support. A background bash call returns its task id, the outer run settles, and a later run polls that id to completion; separate cases prove pre-abort creates no task, post-publication call abort preserves the task, foreground execution stays signal-coupled, and `task_kill` owns cancellation. A Cordis program reads an active or pending mount's id and `waitingFor` fields directly, unmounts by that id, and confirms removal without parsing rendered text.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Return Native text plus optional JSON.** Rejected because the program would have two competing success contracts and would still need tool-specific parsing rules when the optional value is absent. Canonical value is the API; Native content is its presentation.
|
||||
|
||||
**Expose a success/failure union from every binding.** Rejected because failure has no stable programmatic taxonomy. Rejections preserve ordinary `try`/`catch` control flow and expose only the tool name and human-readable message.
|
||||
|
||||
**Cap each intermediate binding.** Rejected because intermediate values are not placed in model context and arbitrary truncation would corrupt programmatic composition. The producer's acquisition contract and process memory remain explicit boundaries.
|
||||
|
||||
**Silently inspect or truncate an oversized completion.** Rejected because changing a JSON value into a string is lossy and type-incorrect. The explicit `output-limit` failure lets the model choose a smaller result, while the retained logs and diagnostic can still use normal outer spill.
|
||||
|
||||
## Consequences
|
||||
|
||||
Code programs can compose tools through stable values instead of reverse-engineering Native prose. Native and Both Mode retain their existing text and editor presentation, while Code Mode receives output-schema types and exact runtime JSON. Tool authors must treat the canonical value as their programmatic API and put display-only formatting in the renderer.
|
||||
|
||||
The worker performs bounded-depth flat-wire transport and lossless validation but does not make intermediate values cheap or durable. Outer overflow is an explicit failed run, and error handling remains intentionally human-guided rather than a versioned code union.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Subagent and workflow caller-defined structured outputs remain object-rooted through consumer-level guards even though tool outputs may use any JSON root.
|
||||
- Post-execute has separate value and presentation projections; replacing content is not a confidentiality mechanism, so policy must block or replace the value to hide it from programmatic callers.
|
||||
- Intermediate canonical values are execution-local and unavailable to replay because durable events persist only presentation and bounded summaries.
|
||||
- Intermediate values have no byte cap and can exhaust process or worker memory through retention, flat-wire copies, or structured-clone cost.
|
||||
- The 64 MiB hard cap applies only to the outer variable payloads, excluding fixed result-envelope syntax and presentation whitespace; spill cannot recover bytes rejected beyond that cap.
|
||||
- Provider or executor acquisition limits may already have discarded source data before a canonical value reaches Code Mode.
|
||||
- Unsupported MCP output schemas fall back to `JsonValue`; richer Native multimedia projection is deferred.
|
||||
- There is one result card per outer `run_code`, never per nested call.
|
||||
- Code failures expose `ToolCallError` message and tool name only, without a programmatic error-code union.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Agent Note:Code Mode 的类型化工具返回值
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-code-mode-typed-tool-returns.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Code Mode 过去会把每个嵌套工具的结果从 `ContentBlock[]` 重新投影为一个字符串。这样虽然保留了适合人类阅读的 Native 接口,却丢失了工具已经生成的规范结果:程序只能从自然语言中提取 task id 和动态挂载 id;结构化搜索与工作流结果失去原有形态;非文本块则变为占位符。生成的 SDK 可以描述参数,却无论工具实际输出为何都只能承诺 `Promise<string>`。
|
||||
|
||||
运行时还把绑定值和程序最终返回值当作展示数据。日志和完成值分别设置上限,导致过大或无法克隆的完成值可能被替换为检查格式化后的文本,而中间值本来就不会进入模型上下文。这种设计使程序化组合产生信息损失,也混淆了内存边界与提示词边界。
|
||||
|
||||
[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)确立了单一、经过校验的执行期值,并将 Native 渲染器与之分离。Code Mode 应直接消费该值,在跨越 worker 边界时完整保留它,并且只限制程序有意返回给模型的最终输出。
|
||||
|
||||
## 决策
|
||||
|
||||
Code Mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则以真正的 `ToolCallError` reject。中间值只存在于本次运行中,并完整跨越 worker 边界。只有外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线。
|
||||
|
||||
本文档定义叠加在原始 [Code Mode 基础](2026-06-15-code-mode.md)之上的返回值与失败契约。统一 schema 词汇由 [JSON 值 schema DSL Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md)定义;Native 渲染与策略投影仍由规范输出 Agent Note 定义。
|
||||
|
||||
### 生成的 SDK
|
||||
|
||||
每次组装提示词时,注册表都会把每个可见工具的参数 schema 及其分离的规范输出 schema 投影为一份确定性声明:
|
||||
|
||||
```ts ignore-check
|
||||
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
interface ToolArgsMap {
|
||||
// one exact inferred entry per visible tool
|
||||
}
|
||||
|
||||
interface ToolOutputMap {
|
||||
// one exact inferred entry per visible tool
|
||||
}
|
||||
|
||||
type ToolName = keyof ToolOutputMap
|
||||
|
||||
declare class ToolCallError extends Error {
|
||||
readonly name: 'ToolCallError'
|
||||
readonly toolName: ToolName
|
||||
}
|
||||
|
||||
declare const tools: {
|
||||
[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>
|
||||
}
|
||||
```
|
||||
|
||||
`jsonSchemaToTs()` 覆盖统一 schema 支持的所有节点:对象、数组、字符串、数字、整数、布尔值、null、无约束 JSON、标量 `enum` 与 `const`,以及 `oneOf`。提示词生成期间,不支持的原始结构会降级为 `unknown`,而不会导致组装失败。工具名会保留精确键名,包括必须使用引号访问的名称。
|
||||
|
||||
### 绑定值与失败
|
||||
|
||||
分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前 reject。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。
|
||||
|
||||
Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其 reject 异常能力。运行时 seam 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。worker 使用模块初始化时捕获的 Error 构造函数与属性定义内建方法,配合原型为 null 的属性描述符,构造失败对象并定义其公开字段,因此模型代码的修改不会把契约承诺的 reject 变成 worker 失败。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常契约,而不是供程序分类的失败联合。
|
||||
|
||||
绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,且不设字节上限。每个分离后的值在通过结构化克隆跨越边界前,都会编码为扁平的前序 token 流,其传输结构的嵌套深度有界;接收方再以迭代方式重建该值。因此,有效应用数据的嵌套深度既不受 JavaScript 调用栈深度上限限制,也不受特定平台对嵌套结构化克隆施加的上限限制。模块初始化时,worker 会捕获自身 JavaScript 运行域中 `Array.prototype` 和 `Object.prototype` 的引用、仅用于识别其他运行域普通容器原型的原生函数源码内建方法,以及 JSON 边界用于结构处理和计量的全部内建方法。属性写入使用原型为 null 的属性描述符;内部的数组与集合操作直接调用捕获的方法,不会访问可变的全局或原型槽位。因此,即使模型代码替换 `Object.keys`、`Array.isArray`、集合方法、字符串方法或 `Buffer.byteLength` 等辅助方法,重写内建原型的构造函数槽位,或向 `Object.prototype` 添加形如属性描述符的字段,也不会改变校验、协议传输或字节计量。面向其他运行域的原生函数源码检查仍会拒绝由用户编写、冒充 `Object` 或 `Array` 的构造函数。为保持依赖轻量,运行时 seam 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文输出落盘或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。
|
||||
|
||||
### 外层结果与输出账本
|
||||
|
||||
运行时接受以任意 JSON 类型为根的精确无损完成值。返回 `undefined` 表示省略完成值;返回 `null` 则是显式结果。`run_code` 暴露规范外层值 `{ logs: string[], result?: JsonValue }`。其 Native 渲染器先输出日志;字符串结果保持原文,其他所有 JSON 根值则使用迭代式美化渲染器。总缩进长度上限为 10 个字符,更深的子树保持紧凑格式,既保留既有的浅层文本,又确保遍历不受调用栈深度限制,且格式化输出大小与规范 JSON 大小呈线性关系。
|
||||
|
||||
`WorkerCodeRuntime` 以可配置的 `maxOutputBytes` 取代彼此独立的日志与值上限,默认值为 `67_108_864` 字节。worker 会将已捕获日志序列化为 JSON 字符串后的精确字节数计入账本,并在发送终态消息前,根据组合账本的剩余额度预检分离后的完成值或程序异常。因此,即使抛出的字符串或堆栈极大,通过 worker 端口的也只会是固定的 `output-limit` 诊断。宿主侧会针对伪造流量以及 worker 无法观察的原生管道写入,重复执行这套不可信对端计账。固定的 `CodeRunResult` 字段名、花括号、有界的错误类型标签及后续展示空白有意不计入这份可变负载账本。这两个阶段都不会实际生成超出上限的完成值序列化结果。结果不超过上限时会保持精确。完成值无法通过无损 JSON 快照时,以 `invalid-output` 失败;值、诊断或包含日志的组合结果超过上限时,以 `output-limit` 失败,而不会变成检查格式化后或截断的文本。
|
||||
|
||||
日志会在产生时立即流出,因此运行被终止时仍可保留已经纳入额度的输出。绕过 worker 中已改写流写入入口的原生 stdout 和 stderr 写入会经由彼此独立的管道传输,因此运行时在终态结算期间仍会继续在上限内捕获输出,直至 worker 完全终止,然后才组装结果。超过上限后,运行时会返回一个显式的有界失败,并携带可容纳的已捕获前缀。该外层结果随后通过普通的 `run_code` 渲染与输出落盘策略;策略可以保存已捕获的文本,并暴露其配置指定的头尾预览。输出落盘层无法恢复运行时在硬上限之外拒绝的字节。
|
||||
|
||||
计算时间、墙钟时间、worker 堆内存、取消和每次运行使用全新 worker 的隔离仍是互相独立的限制。外层账本从不计入中间绑定值,因此生成快照、扁平协议格式的编码与解码、结构化克隆开销,以及进程或 worker 的可用内存构成了这些值的实际边界。
|
||||
|
||||
### 类型化句柄与生命周期
|
||||
|
||||
后台 producer 返回类型化的规范句柄,例如 `{ kind: 'background', taskId }`,同时保留既有的 Native 语句。已预先中止的后台调用仍是失败,因为成功输出承诺返回 id,而此时并未创建任务。`ctx.tasks.start()` 发布 id 后,工作由任务自有的取消机制控制:外围 `run_code` 调用完成,或随后被取消,都不会终止该任务。后续程序可以把返回的 id 传给 `task_output`;取消则由 `task_kill`、owner dispose 或服务 teardown 负责。前台执行仍与本次调用的信号耦合。任务生命周期契约由[后台任务运行时 Agent Note](../architecture/2026-06-20-generic-long-running-tool-runtime.md)定义。
|
||||
|
||||
动态 Cordis 挂载遵循同一规则:`cordis_mount` 返回 `{ id, pluginName, state, provides, waitingFor }`,因此程序可以直接读取 `mounted.id`,检查 active 或 pending 状态,并把该 id 传给 `cordis_unmount`,无需解析稳定的 Native 语句。
|
||||
|
||||
### 持久化、元数据与输出落盘
|
||||
|
||||
嵌套分发会为诊断保留既有的有界 `tool/code-dispatch.resultSummary`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。这并非会话格式变更,因此 `SESSION_FORMAT_VERSION` 保持不变,回放也无法重建程序的中间值。
|
||||
|
||||
不透明的 `exec.parent` token 用于标识嵌套调用。由于这些调用没有直接对应的结果卡片,而且其规范值永远不会进入上下文,展示元数据以及通用或工具自有的输出落盘投影都会跳过它们。只有外层 `run_code` 调用会生成一张卡片,并且可能将 post-policy 处理后的最终展示写入落盘文件;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 ACP 和 TUI 通过其通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。
|
||||
|
||||
## 测试
|
||||
|
||||
编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围以及特殊名称。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明并用于 reject 的真实异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。
|
||||
|
||||
无密钥的真实 worker 集成测试锁定了自然语言结果无法安全支持的两种句柄工作流。后台 bash 调用返回 task id,外层运行结束,之后的运行再根据该 id 轮询直至任务完成;其他用例分别证明,预先中止不会创建任务、发布后的调用取消会保留任务、前台执行仍与信号耦合,并且取消归 `task_kill` 所有。Cordis 程序会直接读取 active 或 pending 挂载的 id 和 `waitingFor` 字段,按该 id 卸载,并在不解析渲染文本的情况下确认挂载已移除。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**返回 Native 文本并附加可选 JSON:**不予采纳。程序会面对两套相互竞争的成功契约;可选值不存在时,仍需使用工具专属的解析规则。规范值才是 API;Native 内容只是它的展示。
|
||||
|
||||
**让每个绑定返回成功/失败联合:**不予采纳。失败没有稳定的程序化分类体系。reject 保留普通的 `try`/`catch` 控制流,并且只暴露工具名与可供人阅读的消息。
|
||||
|
||||
**限制每个中间绑定值:**不予采纳。中间值不会进入模型上下文,任意截断会破坏程序化组合。明确的边界仍是生产方的采集契约与进程内存。
|
||||
|
||||
**静默检查格式化或截断过大的完成值:**不予采纳。把 JSON 值改成字符串既有损又违反类型。显式的 `output-limit` 失败让模型可以选择返回更小的结果,而保留的日志和诊断仍可使用普通的外层输出落盘机制。
|
||||
|
||||
## 影响
|
||||
|
||||
Code Mode 程序可以通过稳定值组合工具,无需逆向解析 Native 自然语言。Native 和 Both Mode 保留现有文本与编辑器展示,Code Mode 则获得输出 schema 类型和精确的运行时 JSON。工具作者必须把规范值视为程序化 API,并将仅用于展示的格式化放入渲染器。
|
||||
|
||||
worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损校验,但不会降低中间值的开销,也不会使其具备持久性。外层输出溢出会显式导致运行失败,错误处理则有意由人类引导,而不是依赖带版本的错误代码联合。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- 即使工具输出可以采用任意 JSON 根,subagent 和工作流中由调用方定义的结构化输出仍通过消费方级别的门禁保持对象根限制。
|
||||
- Post-execute 分别提供值投影与展示投影;替换内容不是保密机制,因此策略若需向程序化调用方隐藏内容,就必须阻止调用或替换值。
|
||||
- 中间规范值仅存在于执行期间,无法用于回放,因为持久事件只存储展示和有界摘要。
|
||||
- 中间值没有字节上限,可能因值的保留、扁平协议格式副本或结构化克隆开销而耗尽进程或 worker 内存。
|
||||
- 64 MiB 硬上限只适用于外层可变负载,不计固定的结果封装语法与展示空白;输出落盘无法恢复超出该上限后被拒绝的字节。
|
||||
- 提供方或执行器的采集上限可能在规范值到达 Code Mode 前就已丢弃部分源数据。
|
||||
- 不支持的 MCP 输出 schema 会回退为 `JsonValue`;更丰富的 Native 多媒体投影留待后续实现。
|
||||
- 每个外层 `run_code` 只有一张结果卡片,嵌套调用不会各自生成卡片。
|
||||
- Code Mode 失败只暴露 `ToolCallError` 的消息与工具名,不提供程序可用的错误代码联合。
|
||||
@@ -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
|
||||
2026-07-20-dsh-cli-personal-config.md: e349374a6bc7fc0137bf14836469aef8bae8d49d
|
||||
2026-07-20-dsh-cli-personal-config.zh.md: 88210dc386a245002de927950dab2852e40218ea
|
||||
2026-07-20-dsh-cli-personal-config.md: 514bb5b12a3e04c7deaad1e8616472eed1c920e1
|
||||
2026-07-20-dsh-cli-personal-config.zh.md: 16fada82c59c8a356e6df112234e6b7565aae1bf
|
||||
@@ -22,6 +22,8 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh
|
||||
|
||||
The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes.
|
||||
|
||||
Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain.
|
||||
|
||||
@@ -22,6 +22,8 @@ Status: implemented
|
||||
|
||||
PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。
|
||||
|
||||
与热重载的交互:include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字。** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web`、`-p`)的 `dsh` CLI,并且默认位空缺。两个互相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。
|
||||
|
||||
@@ -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
|
||||
2026-07-21-tui-reload-command.md: de9a5502214a610d88024730b1c0c1044a396c92
|
||||
2026-07-21-tui-reload-command.zh.md: 25d1d448459221698ca63377f8f18d05a0fa3d21
|
||||
2026-07-21-tui-reload-command.md: e5600f0ab5cd82dc556df76006fcf532d8c7d302
|
||||
2026-07-21-tui-reload-command.zh.md: 3798b0518df1c379cca808bd4af38490016567cb
|
||||
@@ -10,7 +10,7 @@ HMR's file watcher only reacts to in-place `change` events under its configured
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`).
|
||||
`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`); invalid files warn and keep the running tree (the hot-reload-resilience contract); include `patches` — including the dsh CLI's personal overlay — re-apply on every re-read.
|
||||
|
||||
The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not `inject`): tests and embedders run the TUI without a Loader, where `/reload` degrades to a warning notice instead of failing the mount. Module-source hot reload stays watcher-owned; `/reload` refreshes configs only.
|
||||
|
||||
@@ -28,8 +28,8 @@ The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not
|
||||
- The command reports tree count and completion as transcript notices; per-file failures surface only in loader logs, which the TUI does not display — acceptable for a dev-only surface, noted in the completion message.
|
||||
- A re-entrancy guard serializes reloads: `/reload` while one is in flight is refused with a warning, keeping the loader's unmutexed tree-update pass single-writer; the guard releases on completion or failure.
|
||||
- `/reload` runs only while the agent is idle: a reload can dispose and re-mount entries, which under an active turn could tear tools or the adapter out from under in-flight calls. The check is advisory (a send can race in after it) but removes the common footgun.
|
||||
- If any `refresh()` rejects, the command reports the failure instead of leaving an unhandled rejection.
|
||||
- If `refresh()`'s never-reject contract ever changes, the command reports the failure instead of leaving an unhandled rejection.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: a probe edit reloads successfully.
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: probe edit → reload applies; invalid edit → reload keeps the running tree.
|
||||
@@ -10,7 +10,7 @@ HMR 的文件监听器只对其配置根目录(示例中即配置叶子所在
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较)。
|
||||
`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较);无效文件记录警告并保留运行中的树(热重载韧性契约);include 的 `patches`——包括 dsh CLI 的个人 overlay——在每次重读时重新应用。
|
||||
|
||||
TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`,而非 `inject`):测试和嵌入方在没有 Loader 的情况下运行 TUI,此时 `/reload` 退化为一条警告通知而不是挂载失败。模块源码热重载仍由监听器负责;`/reload` 只刷新配置。
|
||||
|
||||
@@ -28,8 +28,8 @@ TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`,
|
||||
- 命令以 transcript 通知报告树数量与完成;单文件失败只出现在 loader 日志里,TUI 不显示——对仅供开发的表面可以接受,完成消息中已注明。
|
||||
- 重入保护串行化重载:前一次进行中时 `/reload` 会被拒绝并提示警告,使 loader 无互斥的树更新过程保持单写者;保护在完成或失败时释放。
|
||||
- `/reload` 只在 agent 空闲时运行:重载可能卸载并重新挂载配置项,在活跃轮次下这会把工具或适配器从进行中的调用脚下抽掉。检查是建议性的(检查后仍可能有 send 竞争进来),但消除了常见的坑。
|
||||
- 任一 `refresh()` 若 reject,命令会报告失败而不是留下未处理的 rejection。
|
||||
- 若 `refresh()` 的永不 reject 契约将来改变,命令会报告失败而不是留下未处理的 rejection。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑后 reload 成功生效。
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑 → reload 生效;无效编辑 → reload 保留运行中的树。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-tui-file-reference-autocomplete.md: 1a136009213c845af28f4ac47a8b31d426ac8cf5
|
||||
2026-07-23-tui-file-reference-autocomplete.zh.md: 410f0d49dbd20a2dcf704892a192406020aaa86e
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: TUI file-reference autocomplete
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-tui-file-reference-autocomplete.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The TUI offered structured `@session` references but no dependable way to discover workspace paths while composing a prompt. Requiring users to remember exact paths made file-oriented requests unnecessarily awkward, while eagerly attaching every selected file would spend context before the model knew whether its contents were relevant and would hide the normal `read` observation from the tool transcript.
|
||||
|
||||
## Decision
|
||||
|
||||
The TUI owns a bounded, cancellable host-workspace path index rooted at the active session's working directory. Typing `@` at a token boundary fuzzy-matches files and directories; queries containing `/` list the named directory directly, accepting a directory continues completion, and paths containing whitespace use the `@"path with spaces"` form. Configuration controls result count, index size, and excluded directory basenames. The default exclusions are `.git` and `node_modules`; traversal does not follow directory symlinks or interpret ignore files.
|
||||
|
||||
Selecting a file changes only the editor text. The submitted user message retains the natural `@path` spelling and carries no injected contents, hidden context, or reference object. When the model-facing `read` tool is registered, the TUI contributes a stable system-prompt section that identifies `@` paths as explicit user references, directs the model to call `read` when contents are needed, and forbids claiming inspection before that call. Tool results invalidate the reusable fuzzy index so subsequent interactions observe likely workspace mutations.
|
||||
|
||||
Structured session mentions keep their existing snapshot preparation. Unlike files, a referenced session has no general model-facing retrieval tool, so reducing `@session` to a path-like label would make its content unreachable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Eagerly inject selected file contents.** This spends tokens before relevance is known, can capture stale content before execution reaches the reference, and bypasses the auditable `read` call/result sequence.
|
||||
|
||||
**Require an external file finder.** Depending on `fd`, `rg --files`, or another executable would make baseline completion vary by host installation and complicate cancellation and cross-platform behavior.
|
||||
|
||||
**Use the filesystem service's ordinary directory-list operation for discovery.** That seam is optimized for exact model-facing filesystem operations and may represent a remote namespace; recursive fuzzy indexing would multiply provider round trips and couple editor latency to tool policy. Host-side discovery keeps the terminal interaction local, while the documented namespace-alignment limitation remains explicit for non-local deployments.
|
||||
|
||||
**Add a new cross-package file-search capability.** The TUI is the only current consumer and the behavior is editor presentation rather than a model capability, so a new interface, implementation, and consumer package set would split the seam prematurely.
|
||||
|
||||
## Consequences
|
||||
|
||||
Users can discover and insert paths without making selection itself expensive or model-visible beyond the path. The model preserves agency over whether to inspect a file, and any inspection remains reconstructable through the logged tool transcript. The fixed instruction slightly enlarges TUI system prompts when `read` is present, and content-requiring requests take an additional tool round trip.
|
||||
|
||||
Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align the TUI host working directory with the `read` namespace or supply a different completion surface. Package tests pin token grammar, ranking, bounds, cancellation, invalidation, and path-only submission; terminal snapshots and the real Loader PTY smoke pin the visible menu and keyboard completion.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: TUI 文件引用自动补全
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-tui-file-reference-autocomplete.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
TUI 提供结构化的 `@session` 引用,但用户在编辑提示词时无法可靠地发现工作区路径。要求用户记住准确路径会给面向文件的请求带来不必要的麻烦;如果直接附加每个选中文件,则会在模型判断其内容是否相关之前占用上下文,并在工具 transcript(文本记录)中隐藏常规的 `read` 观察结果。
|
||||
|
||||
## 决策
|
||||
|
||||
TUI 维护一个有容量上限且可取消的主机工作区路径索引,以活跃会话的工作目录为根。在 token 边界输入 `@` 会对文件和目录进行模糊匹配;查询包含 `/` 时会直接列出指定目录,接受目录后会继续补全,包含空白的路径采用 `@"path with spaces"` 形式。配置项控制结果数量、索引大小以及排除的目录基名。默认排除 `.git` 和 `node_modules`;遍历既不跟随目录符号链接,也不解析忽略文件。
|
||||
|
||||
选择文件只会改变编辑器文本。提交的用户消息保留自然的 `@path` 写法,不携带注入的内容、隐藏上下文或引用对象。注册面向模型的 `read` 工具时,TUI 会加入一个稳定的系统提示词段,说明 `@` 路径是用户的显式引用,指示模型在需要内容时调用 `read`,并禁止模型在调用前声称已检查文件。工具结果会使可复用的模糊索引失效,后续交互因而能看到工作区中可能发生的变更。
|
||||
|
||||
结构化会话提及保留现有的快照准备方式。与文件不同,被引用的会话没有通用的模型侧检索工具;如果把 `@session` 简化为类似路径的标签,模型将无法获取其内容。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**直接注入选中文件的内容。** 这种方式会在确定相关性前消耗 token,可能在执行到该引用前捕获到陈旧内容,并绕过可审计的 `read` 调用与结果序列。
|
||||
|
||||
**要求使用外部文件查找器。** 依赖 `fd`、`rg --files` 或其他可执行文件,会使基础补全行为随主机安装情况而变化,也会增加取消处理和跨平台支持的复杂度。
|
||||
|
||||
**使用文件系统服务的常规目录列表操作进行发现。** 该 seam 针对面向模型的准确文件系统操作进行了优化,并且可能表示远程命名空间;递归模糊索引会增加提供方往返次数,并使编辑器延迟与工具策略耦合。主机侧发现让终端交互保留在本地,同时文档仍明确说明非本地部署中的命名空间对齐限制。
|
||||
|
||||
**新增跨包的文件搜索功能。** TUI 是目前唯一的消费方,而且该行为属于编辑器呈现而非模型功能;新增一组接口、实现和消费方包会过早拆分这条 seam。
|
||||
|
||||
## 影响
|
||||
|
||||
用户可以发现并插入路径,而选择操作本身不会带来高开销,对模型可见的内容也仅限路径。模型仍可自行决定是否检查文件,任何检查都能通过已记录的工具 transcript 重建。存在 `read` 时,固定指令会略微增大 TUI 系统提示词;需要文件内容的请求还会增加一次工具往返。
|
||||
|
||||
补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让 TUI 的主机工作目录与 `read` 命名空间对齐,否则需要提供不同的补全接口。包(package)测试固定 token 语法、排序、边界、取消、失效和仅提交路径的行为;终端快照与真实 Loader PTY 冒烟测试固定可见菜单和键盘补全。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-web-assistant-markdown.md: ce98a16fa43e2743c18826ee7f2344c38e7c70e7
|
||||
2026-07-23-web-assistant-markdown.zh.md: 0d6fd2f9e6b91f76830586ecf4c29774e5d6978a
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Safe assistant Markdown in the Web conversation
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-web-assistant-markdown.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web conversation preserves assistant Markdown source through session events, history replay, and streaming accumulation, but its terminal text primitive renders that source literally. Changing the shared primitive would also format user and steering messages, while parsing in the runtime would mix presentation state into the React-free session projection.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
|
||||
|
||||
`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without `dangerouslySetInnerHTML`, raw-HTML parsing, or syntax highlighting. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser is part of the initial browser bundle.
|
||||
|
||||
## Untrusted output policy
|
||||
|
||||
Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline.
|
||||
|
||||
The renderer uses existing `--dsw-*` typography and color tokens. Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path.
|
||||
|
||||
**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored surfaces remain literal until the product chooses that behavior explicitly.
|
||||
|
||||
**Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead.
|
||||
|
||||
**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies.
|
||||
|
||||
## Consequences
|
||||
|
||||
Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. The initial Web shell grows by the Markdown parser and GFM runtime, and future extensions such as syntax highlighting or remote media require a separate bundle and security decision.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Web 对话中安全的 assistant Markdown
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-web-assistant-markdown.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 对话通过会话事件、历史回放与流式累积保留 assistant Markdown 源文本,但其最末端的文本原语会按字面渲染源文本。若修改共享原语,用户消息与 steering(中途引导)消息也会被格式化;若在运行时中解析,则会把呈现状态混入不依赖 React 的会话投影。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
|
||||
|
||||
`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它支持 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,但不使用 `dangerouslySetInnerHTML`,不解析原始 HTML,也不进行语法高亮。`ui-primitives` 显式声明该依赖;由于这一纯库由 Web shell 预置,解析器会成为初始浏览器 bundle 的一部分。
|
||||
|
||||
## 不受信任输出策略
|
||||
|
||||
assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。
|
||||
|
||||
渲染器使用现有的 `--dsw-*` 排版与颜色 token。围栏代码块与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。
|
||||
|
||||
**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这两类输入内容仍按字面渲染。
|
||||
|
||||
**将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。
|
||||
|
||||
**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。
|
||||
|
||||
## 后果
|
||||
|
||||
assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。初始 Web shell 的体积会因加入 Markdown 解析器与 GFM 运行时而增大;语法高亮或远程媒体等后续扩展需要另行作出 bundle 与安全决策。
|
||||
@@ -15,7 +15,7 @@ Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-b
|
||||
- Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*/*']` (explicit globs keep bundling to vendored Cordis and the TypeScript package tree; `workspace: true` would also discover example manifests and non-bundled workspace members).
|
||||
- Shared shape: entry `lib/types/index.js`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ also holds TSC's `lib/types` intermediate tree). The entry was originally `src/index.ts`; the [TSC-first build Agent Note](2026-06-17-ts-build-config.md) later moved tsdown to bundling TSC-emitted JS so TypeScript transform behavior comes from one compiler.
|
||||
- Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape).
|
||||
- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b tsconfig.build.json && tsdown`.
|
||||
- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b && tsdown` (the root solution owns the emit graph).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Agent Note: TSC-first build and one tsconfig
|
||||
# Agent Note: TSC-first build and one compiler ownership
|
||||
|
||||
Status: implemented
|
||||
|
||||
> Root project topology (which tsconfig owns which graph) has since moved to a solution root over two aggregate programs; see the [solution-root note](2026-07-22-tsconfig-solution-root-two-aggregates.md). The tsc-first pipeline decided here is unchanged.
|
||||
|
||||
## Problem
|
||||
|
||||
The current TypeScript build and typecheck setup had these issues:
|
||||
@@ -28,29 +30,29 @@ In-package relative imports use explicit `.ts` specifiers.
|
||||
|
||||
`pnpm run build` is a two-stage build:
|
||||
|
||||
- Stage 1: `tsc -b tsconfig.build.json` emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`.
|
||||
- The build project uses the project-reference graph that `tsc -b` compiles. For example, root `tsconfig.build.json` references package and vendor tsconfigs. It validates and emits package/vendor build results.
|
||||
- Stage 1: `tsc -b` over the root solution emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`.
|
||||
- The graph is the project-reference graph reachable from the root solution `tsconfig.json` through the two aggregates ([topology](2026-07-22-tsconfig-solution-root-two-aggregates.md)). It validates and emits package/vendor build results.
|
||||
- Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations.
|
||||
|
||||
`tsdown` is no longer the owner of TypeScript compilation or declaration output.
|
||||
|
||||
`pnpm run typecheck` runs build mode over the root `tsconfig.json`.
|
||||
- The root `tsconfig.json` is the single development/typecheck project. It typechecks examples, tests, and scripts with `noEmit`, and validates package/vendor source through references.
|
||||
- Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`.
|
||||
- The root no-emit project disables `rewriteRelativeImportExtensions`; it emits nothing and includes tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled.
|
||||
`pnpm run typecheck` runs the same `tsc -b` graph.
|
||||
- The aggregates (`tsconfig.host.json`, `tsconfig.client.json`) typecheck examples, tests, and scripts with `noEmit`, and validate package/vendor source through references.
|
||||
- Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`.
|
||||
- The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled.
|
||||
|
||||
The command orchestration shape is:
|
||||
|
||||
```sh
|
||||
pnpm run build:
|
||||
tsc -b tsconfig.build.json
|
||||
tsc -b
|
||||
tsdown
|
||||
|
||||
pnpm run verify-node-next-types:
|
||||
tsx scripts/verify-node-next-types.ts
|
||||
|
||||
pnpm run typecheck:
|
||||
tsc -b tsconfig.json
|
||||
tsc -b
|
||||
```
|
||||
|
||||
`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step.
|
||||
@@ -65,7 +67,7 @@ tsc -b tsconfig.json
|
||||
Build responsibilities are clearer:
|
||||
|
||||
- Each module under `packages/<group>/<pkg>` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`.
|
||||
- The `build` command uses `tsconfig.build.json`. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`.
|
||||
- The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`.
|
||||
- `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output.
|
||||
- `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files.
|
||||
- `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target.
|
||||
|
||||
@@ -19,7 +19,7 @@ The scoping line was not picked top-down; it was discovered by testing candidate
|
||||
The rule that settled the remaining cases: ***the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.*** Worked through:
|
||||
|
||||
- A data structure is **core** if it flows through the agent-loop spine — the loop holds, derives, streams, or logs it on every turn regardless of which plugins load (`Message`, `StreamChunk`, `SessionEvent`, the `Agent` handle) — **or** it is the single headline type a plugin author writes against a pipeline (`ToolDefinition`).
|
||||
- `ToolDefinition` is core (it is what every tool author writes) **even though the loop never holds one** — authoring-importance overrides the strict flows-through-spine rule for this one headline type. But its typing machinery — the `SchemaSpec`/`InferArgs` DSL — is a sub-page detail (you write a `ToolDefinition`; the type-level machinery that types it you do not). That is the spine-vs-seam line made sharp.
|
||||
- `ToolDefinition` is core (it is what every tool author writes) **even though the loop never holds one** — authoring-importance overrides the strict flows-through-spine rule for this one headline type. But its typing machinery — `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, and `InferArgs` — is a sub-page detail. That is the spine-vs-seam line made sharp.
|
||||
- `ToolSchema` is core (it is a field of `GenerateOptions`, the model request that flows through every step) even though it is conceptually part of the tool pipeline — *flows through the spine* wins over *conceptual home* when they conflict.
|
||||
- The tool-presentation vocabulary (`ToolCallView`/`ToolResultView`, …), the `SessionPersistence` durability seam, and bash vocabulary are sub-pages.
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-19-web-styling-system.md: c80ef0d56a0e57b38fbb52bd07cbc0f69ec85912
|
||||
2026-07-19-web-styling-system.zh.md: 59013a4a950196f3a065ac18415f9b5ed42f3ec3
|
||||
2026-07-19-web-styling-system.md: b4d647924ab6ab172cd7a7e2531a10a2a7e62981
|
||||
2026-07-19-web-styling-system.zh.md: 01064d4d52b3ed2b179a4795f5113b94480945bd
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
> Token-system update (2026-07-22): the framework rulings here (CSS Modules + clsx, no component library, no tailwind, tokens-only colors) remain in force, but the two-layer `--bg-*`/`--text-*` token table and its `web-ui/src/style/global.css` home were replaced by the `--dsw-*` static+alias sheets in `packages/client/ui-theme/src/styles/` (dark = `body[data-ds-dark-theme]` override). Current authority: `missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §15.
|
||||
> Token-system update (2026-07-22): the framework rulings here (CSS Modules + clsx, no component library, no tailwind, tokens-only colors) remain in force, but the two-layer `--bg-*`/`--text-*` token table and its `web-ui/src/style/global.css` home were replaced by the `--dsw-*` static+alias sheets in `packages/client/ui-theme/src/styles/` (dark = `body[data-ds-dark-theme]` override) — the sheets themselves are the token authority.
|
||||
|
||||
English | [中文](2026-07-19-web-styling-system.zh.md)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、组件只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)。现行权威:`missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §15。
|
||||
> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、组件只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)——样式表本身即 token 权威。
|
||||
|
||||
[English](2026-07-19-web-styling-system.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
|
||||
2026-07-20-gui-testing-system.md: db1b47566f5aa089ffcb10d130ecde1851b93112
|
||||
2026-07-20-gui-testing-system.zh.md: 691c6baf50c1025a09461effd28ac0f1650fb933
|
||||
2026-07-20-gui-testing-system.md: fdd5c7f9d33f9a90ea4afe145265be5fe93e0fc2
|
||||
2026-07-20-gui-testing-system.zh.md: 0ae08133742711b87e9155ddc6f3104b757c1b55
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Current test-system authority: `missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §18.
|
||||
> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Component-spec shape follows the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md): feed props directly — the store share comes from `createXXXStore().create()` (the real engine, the sanctioned zero-machinery path), framework hooks are plain stubs; no render machinery, no provider mounting. Slot ownership/registry semantics are tier-2 territory (`runtime` + `ui-slots` suites), not component specs.
|
||||
|
||||
English | [中文](2026-07-20-gui-testing-system.zh.md)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。测试体系现行权威:`missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §18。
|
||||
> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。组件 spec 形态遵循 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md):props 直喂——store 份额来自 `createXXXStore().create()`(真引擎,获认可的零机械路径),框架 hook 用普通桩;无渲染机械、不挂 provider。坑位归属/注册表语义归 2 层地界(`runtime` + `ui-slots` 套件),不归组件 spec。
|
||||
|
||||
[English](2026-07-20-gui-testing-system.md) | 中文
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
2026-07-21-serial-cross-platform-ci-reference.md: ffc1fd5b37bc6c9e3427ee55a55300f93a1292f3
|
||||
2026-07-21-serial-cross-platform-ci-reference.zh.md: d7f87916865b83973abe6b0708203618cf536c8e
|
||||
2026-07-21-serial-cross-platform-ci-reference.md: b795a0aff62c20967d2c85429c0c6115c1b9585d
|
||||
2026-07-21-serial-cross-platform-ci-reference.zh.md: 223fd9cf20a1d8228cb0c6b1b2f3f95644becae6
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-21-serial-cross-platform-ci-reference.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The pull-request workflow reaches its latency targets by scheduling the complete primary Node inventory concurrently inside one larger runner. The optimized scheduler still should not be its own only completeness oracle: a defect in its gate inventory or dependency graph could omit work while the optimized job stays green.
|
||||
The pull-request workflow consolidates required checks into dedicated Linux and Windows jobs. Those jobs still should not be the only completeness oracle: a defect in their gate inventory or dependency graph could omit work while the required aggregate stays green.
|
||||
|
||||
Encoding the one-minute non-Windows target and three-minute Windows target as job timeouts creates a separate failure mode. Hosted-runner startup and performance vary, so a correct gate can be cancelled at the target boundary before it emits useful diagnostics. The performance objective needs measurement against GitHub timestamps, while correctness needs enough time to finish.
|
||||
|
||||
@@ -14,21 +14,21 @@ Reviewers also need a direct answer to a simpler question: what happens when the
|
||||
|
||||
## Decision
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run only the optimized larger-runner and compatibility jobs. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
|
||||
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
|
||||
|
||||
Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
|
||||
|
||||
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only the optimized jobs; a master push runs only the three serial references. The one-minute non-Windows and three-minute Windows objectives are evaluated from completed hosted-job timestamps and reported as measurements; they are not `timeout-minutes` values.
|
||||
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
|
||||
|
||||
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. A higher-core hosted runner remains a possible future benchmark, but it is not the default: larger runners require organization-owned labels and provisioning, while a reference oracle should remain runnable without repository-external runner configuration. Provisioning one later can change the performance experiment without changing this correctness baseline.
|
||||
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression.
|
||||
- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
|
||||
- **Run the serial references on every pull request** - rejected because they deliberately trade wall time and runner consumption for simplicity and are not needed in the fast feedback loop.
|
||||
- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts.
|
||||
- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
|
||||
- **Run the serial reference on larger runners** - rejected because the reference is the portable fallback for the organization-specific pull-request topology. The fast pull-request path uses provisioned larger runners; the serial master path keeps standard labels.
|
||||
- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
+6
-6
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
拉取请求工作流通过在一台更大型运行器内并发调度完整的主 Node 门禁清单来达到延迟目标。优化调度器仍不应成为自身唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使优化作业保持绿灯,也可能漏掉部分工作。
|
||||
拉取请求工作流将必需检查合并到专用的 Linux 和 Windows 作业中。这些作业仍不应成为唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使必需聚合结果保持绿灯,也可能漏掉部分工作。
|
||||
|
||||
将非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标写成作业超时,会引入另一种失败模式。托管运行器的启动时间和性能会波动,因此即使门禁本身正确,也可能在到达目标时间边界时被取消,来不及输出有用的诊断信息。性能目标需要根据 GitHub 时间戳衡量,而正确性验证需要给门禁留足完成时间。
|
||||
|
||||
@@ -14,21 +14,21 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求只运行使用更大型运行器的优化作业和兼容性作业。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
|
||||
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
|
||||
|
||||
每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
|
||||
|
||||
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行优化作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
|
||||
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
|
||||
|
||||
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。仍可将更高核心数的托管运行器作为未来的基准测试,但不将其设为默认选择:更大型运行器需要组织自有的标签和预配,而参考判定基准应无需仓库外部的运行器配置即可运行。日后完成这类预配,可以改变性能实验而无需改变该正确性基线。
|
||||
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。
|
||||
- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
|
||||
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业有意以更长的总耗时和更多运行器用量换取简单性,快速反馈循环不需要它们。
|
||||
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。
|
||||
- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
|
||||
- **在更大型运行器上运行串行参考流程**:不予采纳,因为该参考流程是特定组织拉取请求拓扑的可移植后备方案。快速拉取请求路径使用已预配的更大型运行器;串行 master 路径保留标准标签。
|
||||
- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-cordis-tutorial-docs.md: 45abb8f524218ce0b2678606ae62c7bf2dbca00b
|
||||
2026-07-22-cordis-tutorial-docs.zh.md: cd1a62e2a6e7f2e28bc32109dd67746840f8b8b7
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Tutorial-style Cordis docs under docs/cordis-tutorial
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-cordis-tutorial-docs.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The repo documents Cordis at two levels: the condensed [cordis-primer](../../../../docs/cordis-primer.md) states the concepts, and the `docs/user/develop/` pages teach harness plugin authoring against harness services. Neither serves a developer meeting Cordis itself for the first time: the primer assumes the reader already writes plugins, and the develop pages jump straight to `defineTool` without showing how contexts, fibers, services, and dispatch actually behave. There was no path where a reader runs bare Cordis, watches a fiber go PENDING, or sees a waterfall veto happen.
|
||||
|
||||
## Decision
|
||||
|
||||
`docs/cordis-tutorial/` holds a seven-chapter hands-on tutorial (first plugin → lifecycle/effects → services → events → config → composition/HMR → harness tool). Its properties, in decreasing order of load-bearing-ness:
|
||||
|
||||
- **Every transcript is real.** Each chapter's files run in the gitignored `tmp/cordis-tutorial/` scratch directory via `node --import tsx ../../vendor/cordis/bin.js`, and the shown output is what those commands print. The chapter that uses harness packages (`@deepseek-ai/dsh-tools` and `@deepseek-ai/dsh-llm`) runs keylessly.
|
||||
- **dsh-flavored, not pure Cordis**: later chapters use real harness services and events (`ctx.tools`, `tools/result`) so the tutorial lands the reader inside this repo's actual composition model, per the requesting user's choice.
|
||||
- **English-only, published to both website locales** through `mirroredPages()` in [website/docs.ts](../../../../website/docs.ts) under a `Cordis 教程` / `Cordis tutorial` section of the develop sidebar — the same pattern as the reference pages, so a Chinese pair can ratchet in later without route changes.
|
||||
- Code fences compile under `doc-typecheck` except the two fences that import scratch-relative files (`./stats.ts`) or intentionally throw, which carry `ignore-check`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Under `docs/user/develop/` as paired product docs.** That tier requires en+zh+i18n records in the same PR, roughly doubling the change and coupling every future tutorial edit to a translation. Rejected for the first landing; the mirrored projection keeps the same public visibility.
|
||||
|
||||
**Pure-Cordis tutorial with no harness packages.** Cleaner as framework documentation, but the audience is agent developers extending this harness; ending at `ctx.tools.execute` and `tools/result` teaches the composition they will actually work in. The user chose this explicitly.
|
||||
|
||||
**Extending the primer instead of a new directory.** The primer is a 600-word budgeted concept reference; a multi-chapter walkthrough inside it would break its tier's job (and its budget) rather than complement it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A runnable introduction to Cordis exercises the loader, fiber states, effects, service injection, all five dispatch-mode contracts, Schemastery validation, and HMR. It demonstrates PENDING dependencies and validation failure; it explains the loader's logged unresolved-entry failure because that boot-time log may not reach a console exporter.
|
||||
- The tutorial's transcripts pin behavior informally but are not snapshot-gated; if loader or HMR behavior changes, the transcripts drift until a human replays the chapters. The compile gate covers only the code fences.
|
||||
- The chapters name concrete harness APIs (`ctx.tools.execute`, `CallId`, `tools/result`); renames must update the tutorial like any other doc reference (`verify-md-links` catches file moves, not API prose).
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: `docs/cordis-tutorial` 下的 Cordis 实操教程文档
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-cordis-tutorial-docs.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
本仓库从两个层面介绍 Cordis:精简的 [cordis-primer](../../../../docs/cordis-primer.md) 阐述概念,`docs/user/develop/` 下的页面则讲解如何基于 harness 服务编写 harness 插件。但二者都不适合初次接触 Cordis 的开发者:primer 假定读者已经会编写插件,开发页面则直接从 `defineTool` 讲起,没有展示上下文、fiber、服务和 dispatch 的实际行为。此前没有一条学习路径让读者运行原生 Cordis、观察 fiber 进入 PENDING 状态,或看到 waterfall(瀑布式事件)否决实际发生。
|
||||
|
||||
## 决策
|
||||
|
||||
`docs/cordis-tutorial/` 包含一套七章实操教程(第一个插件 → 生命周期与 effect → 服务 → 事件 → 配置 → 组合与 HMR(热模块替换)→ harness 工具)。以下是教程的特性,按重要性从高到低排列:
|
||||
|
||||
- **每段 transcript(文本记录)都真实可复现。** 每章文件都通过 `node --import tsx ../../vendor/cordis/bin.js` 在 git 忽略的 `tmp/cordis-tutorial/` 临时目录中运行,展示的输出就是这些命令实际打印的内容。使用 harness 包(package)(`@deepseek-ai/dsh-tools` 和 `@deepseek-ai/dsh-llm`)的章节无需密钥即可运行。
|
||||
- **采用 dsh 风格,而非纯 Cordis**:后续章节使用真实的 harness 服务和事件(`ctx.tools`、`tools/result`),使读者最终进入本仓库实际采用的组合模型,这遵循了提出请求的用户所作的选择。
|
||||
- **仅提供英文版,但发布到网站的两个语言区域**:通过 [website/docs.ts](../../../../website/docs.ts) 中的 `mirroredPages()`,发布到开发侧边栏的 `Cordis 教程` / `Cordis tutorial` 分区。该方式与参考页面采用的模式相同,因此日后可以逐步纳入中文配对,而无需更改路由。
|
||||
- 除两个围栏代码块外,其余代码块均通过 `doc-typecheck` 编译;这两个例外分别导入临时目录中的相对路径文件(`./stats.ts`)或有意抛出异常,因此标有 `ignore-check`。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**作为双语产品文档放在 `docs/user/develop/` 下。** 该层级要求在同一个 PR(Pull Request)中同时提供英文、中文和 i18n 记录,这会使变更量大致翻倍,并要求未来每次修改教程时都同步翻译。首次落地不采用此方案;镜像投影仍可保持同等的公开可见性。
|
||||
|
||||
**不使用任何 harness 包的纯 Cordis 教程。** 作为框架文档会更简洁,但目标读者是扩展此 harness 的 agent(智能体)开发者;以 `ctx.tools.execute` 和 `tools/result` 收尾,能讲清他们实际使用的组合方式。用户明确选择了此方案。
|
||||
|
||||
**扩充 primer,而非新建目录。** primer 是一份预算上限为 600 词的精简概念参考;在其中加入多章演练会破坏该文档层级的职责及其篇幅预算,而非形成补充。
|
||||
|
||||
## 结果
|
||||
|
||||
- 现在有了一份可运行的 Cordis 入门教程,涵盖 loader、fiber 状态、effect、服务注入、全部五种 dispatch 模式的契约、Schemastery 校验和 HMR。教程实际展示了依赖处于 PENDING 状态和配置校验失败;对于 loader 记录的配置项解析失败,教程只作说明,因为启动阶段的日志可能无法到达控制台导出器。
|
||||
- 教程中的 transcript 以非正式方式固定了行为,但没有快照门禁;如果 loader 或 HMR 的行为发生变化,transcript 会逐渐偏离实际结果,直到有人重新运行各章。编译门禁只覆盖围栏代码块。
|
||||
- 各章写明了具体的 harness API(`ctx.tools.execute`、`CallId`、`tools/result`);这些 API 重命名时,必须像更新其他文档引用一样同步修改教程(`verify-md-links` 能发现文件移动,但无法发现 API 文字引用变化)。
|
||||
+2
-2
@@ -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
|
||||
2026-07-22-evidence-based-larger-hosted-runners.md: c0fae2841f21c431d6416cd5d421929d70197abb
|
||||
2026-07-22-evidence-based-larger-hosted-runners.zh.md: 51c73a8a631af4f1254c795d09585770fc4e68bb
|
||||
2026-07-22-evidence-based-larger-hosted-runners.md: aaeab4ed9ae9687598f9f1d4a862120405697672
|
||||
2026-07-22-evidence-based-larger-hosted-runners.zh.md: 72b69c85908990a9f35b60f4c0a2ce213f9c8134
|
||||
+19
-17
@@ -12,16 +12,16 @@ Larger runners make it possible to pay setup once and parallelize inside the rep
|
||||
|
||||
## Decision
|
||||
|
||||
The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256; the ceiling does not allocate idle machines or remove the need to bound workflow demand.
|
||||
The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name three 32-core pools directly: Ubuntu 24.04 for exhaustive coverage, Ubuntu latest for the remaining primary Node 24 inventory, and Windows 2025 for blocking Windows contracts. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit.
|
||||
|
||||
Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded:
|
||||
|
||||
- `node 24 / complete` uses one 96-core Linux runner. One checkout, direct selection of the image's preinstalled Node 24 toolcache, pnpm- and ESLint-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 10 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore both caches without saving them, so cache compression and upload do not extend the required job; the master serial reference refreshes those caches outside the pull-request critical path. An uncached exact-head trace put ESLint at 38.11 seconds and coverage at 37.10 seconds, so the small ESLint restore remains useful on the critical path. The read-only job does not persist checkout credentials.
|
||||
- Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory.
|
||||
- `windows node 24 / complete` uses one 32-core Windows runner. One preparation wave feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step.
|
||||
The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work.
|
||||
|
||||
The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture.
|
||||
|
||||
Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time.
|
||||
|
||||
Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim.
|
||||
|
||||
An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction:
|
||||
|
||||
| Complete Linux primary | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores |
|
||||
@@ -38,15 +38,15 @@ The same benchmark measured the required Windows build surfaces across every pro
|
||||
|
||||
Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated.
|
||||
|
||||
The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head production run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. Production therefore avoids the Windows package-store cache, uses restore-only caches on latency-critical pull-request jobs, and bounds outer concurrency so typecheck, lint, coverage, and build do not oversubscribe one host.
|
||||
The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head candidate run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing.
|
||||
|
||||
Three host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. The setup-node action later spent 3.68 seconds printing cached Linux environment details and 46.56 seconds doing the same on Windows after both had already found Node 24.18.0 in the hosted toolcache. The two latency-critical jobs select the newest preinstalled 24.x directory directly, verify its major, and fail loud if the image no longer carries it; compatibility jobs retain setup-node because selecting a non-default runtime is their contract. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation.
|
||||
Host setup remains part of any comparison. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, while `actions/setup-node` spent 46.56 seconds printing cached Windows environment details after finding Node in the hosted toolcache. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation.
|
||||
|
||||
Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Production therefore retains 16 ESLint workers and admits 10 independent repository gates at once, leaving capacity for the worker pools owned by those gates without starving later independent work.
|
||||
Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit.
|
||||
|
||||
Linux coverage caps each project at 16 workers, while Windows keeps the 12-worker cap. The process-bound project contains exactly five suite files, so its fork count cannot reach either cap. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. It also includes the pi-ai adapter suite after two hosted aggregate runs delayed an idle-watchdog socket-close observation past its 100-millisecond test deadline. A 32-worker all-gate run on the 96-core host slowed coverage to 44.6 seconds and made a compute-budget regression cross its one-second threshold, so production stops at 16. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files.
|
||||
The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection.
|
||||
|
||||
The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs.
|
||||
Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -54,11 +54,13 @@ The workflow retains two manual measurement suites. `suite=larger-runner-benchma
|
||||
|
||||
**Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises.
|
||||
|
||||
**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. Production uses 96 cores for the shorter controllable critical path; the benchmark suite retains both pools so a sustained image or pricing change can reverse that choice with evidence.
|
||||
**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. The benchmark suite retains both pools because a sustained image or pricing change can reverse the comparison.
|
||||
|
||||
**Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it.
|
||||
|
||||
**Keep compatibility and Python on standard runners.** Warm standard runs can fit, but runner setup alone has crossed the non-Windows target. Distinct larger pools isolate these environment contracts from that allocation lottery.
|
||||
**Keep static gates and post-build consumers on one runner.** Reusing one workspace avoids a setup wave and artifact transfer, but build-duration variance delays every consumer and leaves their lint and snapshot tails after the static result. A run-scoped built tree preserves one exact build while independent jobs keep both complete paths within the observed target.
|
||||
|
||||
**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path.
|
||||
|
||||
**Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process.
|
||||
|
||||
@@ -66,10 +68,10 @@ The workflow retains two manual measurement suites. `suite=larger-runner-benchma
|
||||
|
||||
## Consequences
|
||||
|
||||
Primary Node CI has one job, one setup wave, one complete gate inventory, and no shard selectors. Together with two Node compatibility executions, Python, and Windows, production has five paid larger-runner executions instead of seven coarse-lane executions or 49 gate-level executions.
|
||||
The required topology pays one setup wave per 32-core lane and retains no shard selectors. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful.
|
||||
|
||||
GitHub rounds each larger-runner execution up to a whole minute, so eliminating setup waves reduces billed time as well as workflow complexity. The final aggregator remains on a standard runner because it begins only after the paid jobs release capacity.
|
||||
GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice and transfers one built tree, but isolates coverage, static gates, and post-build consumers from each other's critical paths without repeating the build; consolidating Windows avoids repeating its slower setup.
|
||||
|
||||
The current targets are observed performance contracts, not cancellation deadlines. Exact-head production runs must show every non-Windows job below one minute and the consolidated Windows job below three minutes; manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement.
|
||||
Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement.
|
||||
|
||||
Production CI depends on the organization-owned runner labels in [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml). Missing or renamed pools leave jobs queued instead of falling back to standard capacity. All twelve pools remain provisioned so the manual benchmarks can re-evaluate the production size without an administrative setup cycle.
|
||||
Missing or renamed enterprise labels leave required primary jobs queued. Standard-hosted compatibility jobs and `master` references still report useful evidence, but they do not substitute for the required aggregate; runner assignment is therefore an operational dependency that repository CI cannot repair.
|
||||
+19
-17
@@ -12,16 +12,16 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256;该上限既不会分配闲置机器,也不能免除限制工作流需求的必要性。
|
||||
企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 3 个 32 核运行器池:Ubuntu 24.04 用于完整覆盖率,Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。
|
||||
|
||||
生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片:
|
||||
|
||||
- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、直接选择托管映像中预装的 Node 24 toolcache、恢复 pnpm 和 ESLint 缓存以及安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 10 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复这两项缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这两项缓存。一次未使用缓存的分支头精确运行轨迹显示,ESLint 耗时 38.11 秒,覆盖率耗时 37.10 秒,因此在关键路径上恢复这个较小的 ESLint 缓存仍有价值。该只读作业不会持久化代码检出凭据。
|
||||
- Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。
|
||||
- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一轮准备工作供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。
|
||||
必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。
|
||||
|
||||
原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。
|
||||
|
||||
Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。
|
||||
|
||||
Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。
|
||||
|
||||
一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程:
|
||||
|
||||
| Linux 完整主流程 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 |
|
||||
@@ -38,15 +38,15 @@ Status: implemented
|
||||
|
||||
Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。
|
||||
|
||||
客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的生产运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,生产环境不使用 Windows 包存储缓存,在对延迟敏感的拉取请求作业中使用只恢复不保存的缓存,并限制外层并发度,以免类型检查、lint、覆盖率和构建在同一台主机上过度争用资源。
|
||||
客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的候选运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。
|
||||
|
||||
3 项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。setup-node action 在 Linux 和 Windows 均已从托管 toolcache 找到 Node 24.18.0 后,仍分别花费 3.68 秒和 46.56 秒输出缓存的环境详情。两个延迟关键作业会直接选择最新的预装 24.x 目录并验证其主版本号;如果映像不再提供该目录,作业会明确报错并失败。兼容性作业仍使用 setup-node,因为选择非默认运行时正是它们的契约。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。
|
||||
任何比较都必须计入主机设置。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上;`actions/setup-node` 从托管 toolcache 找到 Node 后,仍花费 46.56 秒输出缓存的 Windows 环境详情。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。
|
||||
|
||||
内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,生产环境将 ESLint 工作线程上限维持在 16 个,并且同时最多运行 10 项相互独立的仓库门禁,既为这些门禁自身的工作线程池留出容量,又避免后续独立工作因资源不足而迟迟无法启动。
|
||||
内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。
|
||||
|
||||
Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则保留 12 个工作线程的上限。进程约束项目恰好包含 5 个套件文件,因此它的 fork 数量不可能达到任一上限。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。两次托管聚合运行都将空闲看门狗对套接字关闭的观测延迟到超过其 100 毫秒测试截止时间,因此这份清单还包含 pi-ai 适配器套件。在 96 核主机上使用 32 个工作线程运行全部门禁时,覆盖率耗时变慢至 44.6 秒,还使一项计算预算回归超过其 1 秒阈值,因此生产环境将工作线程数限制在 16 个以内。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。
|
||||
进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于能否得到确定的覆盖率结果,而非标称核心数。
|
||||
|
||||
工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考;拉取请求只运行优化后的作业。
|
||||
只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -54,11 +54,13 @@ Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则
|
||||
|
||||
**将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。
|
||||
|
||||
**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。生产环境使用 96 核来缩短可控的关键路径;基准测试套件保留两种规格,因此如果映像或定价发生持续性变化,仍可根据证据反转这项选择。
|
||||
**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。基准测试套件保留两种规格,因为映像或定价的持续变化可能反转比较结果。
|
||||
|
||||
**让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。
|
||||
|
||||
**让兼容性和 Python 继续使用标准运行器。** 标准运行器热运行可以达到目标,但仅运行器设置一项就曾超过非 Windows 目标。不同的大型运行器池可以让这些环境契约免受这种分配波动影响。
|
||||
**将静态门禁和构建后消费方保留在同一台运行器上。** 复用同一个工作区可以省去一轮设置和一次产物传输,但构建耗时的波动会延迟每个消费方,并使消费方的 lint 和快照尾段延续到静态结果之后。仅供本次运行使用的已构建目录树可以保留同一份构建结果,而相互独立的作业能让两条完整路径都保持在实测目标内。
|
||||
|
||||
**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。
|
||||
|
||||
**将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。
|
||||
|
||||
@@ -66,10 +68,10 @@ Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则
|
||||
|
||||
## 后果
|
||||
|
||||
主 Node CI 只有 1 个作业、1 轮设置、1 份完整门禁清单,而且没有分片选择器。加上 2 次 Node 兼容性执行、Python 和 Windows,生产环境共有 5 次付费大型运行器执行,而非 7 次粗粒度通道执行或 49 次门禁级执行。
|
||||
必需拓扑中的每个 32 核通道只承担 1 轮设置开销,且不保留分片选择器。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows 运行器分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。
|
||||
|
||||
GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此消除设置轮次既能减少计费时长,也能降低工作流复杂度。最终聚合作业仍使用标准运行器,因为它只会在付费作业释放容量后启动。
|
||||
GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置并传输一份已构建目录树,但能使覆盖率、静态门禁与构建后消费方不再相互进入关键路径,且无需重复构建;合并 Windows 则避免重复其耗时更长的设置。
|
||||
|
||||
当前目标是基于观测得到的性能契约,而非取消截止时间。分支头精确的生产运行必须表明每个非 Windows 作业都低于 1 分钟,合并后的 Windows 作业低于 3 分钟;当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。
|
||||
性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。
|
||||
|
||||
生产 CI 依赖 [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) 中由组织持有的运行器标签。池缺失或改名会让作业一直排队,不会回退到标准容量。全部 12 个池均保持已预配状态,因此手动基准测试无需再次经过管理配置周期,就能重新评估生产规格。
|
||||
企业级运行器标签缺失或改名时,必需主作业会持续排队。标准托管兼容性作业与 `master` 参考流程仍会报告有用证据,但不能替代必需聚合流程;因此,运行器分配是一项仓库 CI 无法修复的运维依赖。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-tsconfig-solution-root-two-aggregates.md: 19c229693b98ff3825caf935fa647ab85aff0f56
|
||||
2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: becc43de1ef2f6a53b0f6c2285eb64d9b42604f1
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Solution root over two aggregate programs
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-tsconfig-solution-root-two-aggregates.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The GUI split introduced a second aggregate program (`tsconfig.client.json`, [layering RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)) while the root `tsconfig.json` kept doubling as the host aggregate, and `tsconfig.build.json` remained a third, hand-maintained full emit graph. That triple bookkeeping produced four concrete asymmetries:
|
||||
|
||||
- The typecheck and build references lists drifted apart (`packages/goal/command-goal` was in the typecheck graph but missing from the build graph).
|
||||
- The lefthook pre-push hook ran `tsc -b tsconfig.json` only, so client-side type breakage passed the local checkpoint and surfaced in CI.
|
||||
- tsserver discovers only configs named `tsconfig.json`, so client test files sat on no discoverable config chain and fell back to inferred projects (no paths, wrong lib/jsx).
|
||||
- The vitest configs pointed at three different resolution sources (`tsconfig.vitest.json`, the root config, and one hand-written alias).
|
||||
|
||||
## Decision
|
||||
|
||||
One solution root, two check units, one shared base pair, no separate build or vitest config:
|
||||
|
||||
| File | Role | Forms a program? |
|
||||
|---|---|---|
|
||||
| `tsconfig.json` | Solution root: `extends` base, `files: []`, two references; the whole-repo `tsc -b tsconfig.json` graph, the tsserver entry, and the nearest config for get-tsconfig consumers (tsx running `examples/`, `scripts/`, doc fences) whose bare workspace imports resolve through the inherited `paths` | No |
|
||||
| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map; doubles as the resolution facade for vite-tsconfig-paths (no `include`, so it applies to every importer) | No |
|
||||
| `tsconfig.base.client.json` | Browser compiler shape (`jsx: react-jsx`, DOM libs, `types: []`) shared by the client aggregate and every `packages/client/*` package | No |
|
||||
| `tsconfig.host.json` | The former root aggregate, moved verbatim: host packages, examples, tests, scripts, website; excludes `packages/client` | Yes |
|
||||
| `tsconfig.client.json` | Client packages and their tests; extends `tsconfig.base.client.json` | Yes |
|
||||
|
||||
The load-bearing principle: **cordis `Context` declaration-merge collisions exist only inside a `ts.Program`, never in module resolution.** A solution file forms no program, so referencing both aggregates from one root cannot collide the merges; vite-tsconfig-paths reads only `paths` and `include` and discards types, so one facade may span both sides. The only way to explode is to flatten both sides into a single program — hence two derived disciplines: `tsconfig.base.json` never gains `include`/`files` (it would leak into every extending package and narrow the facade), and every repo-wide `ts.Program` consumer (`scripts/ts-project.ts`, doc-typecheck standalone mode) seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly, never the root solution. Program-backed generators and semantic gates intentionally stay host-only; the client side gets program-backed gates only when a real need arrives.
|
||||
|
||||
Commands collapse to one graph and keep the config name explicit: `typecheck` = `tsc -b tsconfig.json`, `build` = `tsc -b tsconfig.json && tsdown`, lefthook pre-push stays `tsc -b tsconfig.json --pretty false` unchanged (the same line now covers both sides through the solution). `tsconfig.build.json` and `tsconfig.vitest.json` are deleted; all vitest configs point vite-tsconfig-paths at `tsconfig.base.json`.
|
||||
|
||||
The solution root `extends` the base deliberately: `examples/` and `scripts/` have no nearer tsconfig, so tsx (get-tsconfig) resolves their workspace imports through the root file. `extends` restores the `paths` map there while `files: []` keeps the file program-less. Their *type checking* is unaffected by this: examples, scripts, and website files are included by the host aggregate.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Rename `tsconfig.build.json` to `tsconfig.host.json`** — rejected: the build graph was the full emit graph including all client packages, not a host graph; the name that fits the former root aggregate is `tsconfig.host.json`, and the build graph itself is subsumed by the solution.
|
||||
- **Point vitest at the root solution** — rejected: a solution has neither `paths` nor `include`, so resolution would become a function of how far the plugin walks references, and the client aggregate's include (tests only, no src) would leave transitive src→src imports unmapped, falling through to `exports` and loading a second copy of module singletons.
|
||||
- **Keep `tsconfig.vitest.json` as a dedicated facade** — retained only as the fallback if vite-tsconfig-paths mishandles an include-less config; the base file already carries the paths map, and an include-less config applies everywhere, which is strictly wider than the facade's hand-kept include list.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `docs/development.md#typescript-project-layout` is the authoritative description; root `AGENTS.md` carries the two disciplines as conventions.
|
||||
- The [ts-build-config note](2026-06-17-ts-build-config.md) keeps ownership of the tsc-first build pipeline (tsc emits, tsdown bundles, `.ts` specifiers with `rewriteRelativeImportExtensions`); its former "one root typecheck project" shape is superseded by this note.
|
||||
- Adding a package registers it in exactly one aggregate's references (host packages in `tsconfig.host.json`, client packages in `tsconfig.client.json`); the build graph needs no separate registration.
|
||||
- The build gate depends on the typecheck gate: both now drive the same `tsc -b` graph, so running them concurrently would race the same `.tsbuildinfo` files.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Agent Note: 以 solution 根文件统辖两个聚合 program
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-tsconfig-solution-root-two-aggregates.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[分层 RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)),根 `tsconfig.json` 则继续兼任宿主侧聚合,`tsconfig.build.json` 还是第三份手工维护的全量 emit 图。三处账本并行,造成四个具体的不对称:
|
||||
|
||||
- 类型检查与构建的 references 列表逐渐脱节(`packages/goal/command-goal` 在类型检查图里,构建图里却没有)。
|
||||
- lefthook 的 pre-push 钩子只运行 `tsc -b tsconfig.json`,客户端侧的类型破坏因此通过本地检查点,直到 CI 才暴露。
|
||||
- tsserver 只发现名为 `tsconfig.json` 的配置,客户端测试文件不在任何可发现的配置链上,回落到推断项目(inferred project),既没有 paths,lib/jsx 也不对。
|
||||
- 各 vitest 配置指向三个不同的解析来源(`tsconfig.vitest.json`、根配置,外加一处手写别名)。
|
||||
|
||||
## 决策
|
||||
|
||||
一个 solution 根文件,两个检查单元,一对共享 base,不再单设 build 或 vitest 配置:
|
||||
|
||||
| 文件 | 角色 | 是否构成 program? |
|
||||
|---|---|---|
|
||||
| `tsconfig.json` | solution 根文件:`extends` base、`files: []`、两条 references;同时是全仓 `tsc -b tsconfig.json` 图、tsserver 入口,以及 get-tsconfig 消费方(tsx 运行 `examples/`、`scripts/`、文档围栏代码块)就近命中的配置,其裸 workspace 导入经继承来的 `paths` 解析 | 否 |
|
||||
| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射;兼任 vite-tsconfig-paths 的解析门面(不含 `include`,因此对每个导入方都生效) | 否 |
|
||||
| `tsconfig.base.client.json` | 浏览器侧编译形态(`jsx: react-jsx`、DOM lib、`types: []`),由客户端聚合与每个 `packages/client/*` 包共享 | 否 |
|
||||
| `tsconfig.host.json` | 原根聚合原样迁入:宿主各包、examples、测试、scripts、website;排除 `packages/client` | 是 |
|
||||
| `tsconfig.client.json` | 客户端各包及其测试;通过 `extends` 继承 `tsconfig.base.client.json` | 是 |
|
||||
|
||||
整个方案立足的原则:**cordis `Context` 的声明合并冲突只存在于同一个 `ts.Program` 内部,从不发生在模块解析中。** solution 文件不构成 program,因此从一个根文件同时引用两个聚合不会让两侧的声明合并相撞;vite-tsconfig-paths 只读取 `paths` 与 `include`、丢弃全部类型信息,因此一个门面可以横跨两侧。唯一会爆炸的做法是把两侧压平进同一个 program,由此推出两条派生纪律:`tsconfig.base.json` 永远不得添加 `include`/`files`(否则会泄漏进每个继承它的包,并收窄门面范围);每个全仓级 `ts.Program` 消费方(`scripts/ts-project.ts`、doc-typecheck 独立模式)都显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子,绝不使用根 solution。基于 program 的生成器与语义门禁有意只留在宿主侧;客户端侧只有在真实需求出现时才引入基于 program 的门禁。
|
||||
|
||||
各命令收敛到一张图,且显式写出配置名:`typecheck` = `tsc -b tsconfig.json`,`build` = `tsc -b tsconfig.json && tsdown`,lefthook pre-push 保持 `tsc -b tsconfig.json --pretty false` 不变(经由 solution,这同一行命令现已覆盖两侧)。`tsconfig.build.json` 与 `tsconfig.vitest.json` 删除;所有 vitest 配置都把 vite-tsconfig-paths 指向 `tsconfig.base.json`。
|
||||
|
||||
solution 根文件刻意 `extends` base:`examples/` 与 `scripts/` 没有更近的 tsconfig,tsx(get-tsconfig)通过根文件解析它们的 workspace 导入。`extends` 把 `paths` 映射带回根文件,`files: []` 则让它始终不构成 program。这不影响两者的*类型检查*:examples、scripts 与 website 的文件由宿主聚合纳入。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **把 `tsconfig.build.json` 改名为 `tsconfig.host.json`**——不予采纳:构建图是包含全部客户端包的全量 emit 图,不是宿主图;`tsconfig.host.json` 这个名字对应的是原根聚合,而构建图本身已被 solution 吸收。
|
||||
- **让 vitest 指向根 solution**——不予采纳:solution 既没有 `paths` 也没有 `include`,解析结果将取决于插件沿 references 走多远;且客户端聚合的 include 只收测试、不收 src,传递的 src→src 导入会失去映射,回落到 `exports`,加载出模块单例的第二份副本。
|
||||
- **保留 `tsconfig.vitest.json` 作为专用门面**——仅保留为后备方案:若 vite-tsconfig-paths 处理不了无 include 的配置再启用;base 文件已经携带 paths 映射,而无 include 的配置处处生效,严格宽于该门面手工维护的 include 列表。
|
||||
|
||||
## 后果
|
||||
|
||||
- `docs/development.md#typescript-project-layout` 是权威描述;根 `AGENTS.md` 以约定形式收录上述两条纪律。
|
||||
- [ts-build-config Agent Note](2026-06-17-ts-build-config.md) 继续拥有 tsc 先行的构建流水线(tsc 负责输出,tsdown 负责打包,`.ts` 说明符配合 `rewriteRelativeImportExtensions`);其原先「单一根类型检查项目」的形态由本文取代。
|
||||
- 新增一个包只登记进恰好一个聚合的 references(宿主包进 `tsconfig.host.json`,客户端包进 `tsconfig.client.json`);构建图无需另行登记。
|
||||
- 构建门禁依赖类型检查门禁:两者现在驱动同一张 `tsc -b` 图,并发运行会在同一批 `.tsbuildinfo` 文件上竞态。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-browser-demo-gif-recording.md: 096edf453d6b61c4d9046b284ef67a460edf4e88
|
||||
2026-07-23-browser-demo-gif-recording.zh.md: f5b8eac1c8dd57a59e9c2293ecc71511078a4896
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Browser demo GIF recording
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-browser-demo-gif-recording.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Browser demonstrations have been assembled with one-off capture and encoding commands. That makes timing and output size inconsistent, encourages continuous recordings that obscure the useful state changes, and can blur the boundary between a genuine server or API flow and a fixture. Combining local recording with attachment upload or pull-request editing also gives a media task unrelated remote-write authority.
|
||||
|
||||
## Decision
|
||||
|
||||
The repository provides the [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) skill for local browser-demo artifacts. It uses the available browser-control workflow, establishes whether the requested flow is real, fixture-backed, or otherwise simulated, and captures a small storyboard only after semantically observable UI states. Frames and the output live outside the Git worktree by default.
|
||||
|
||||
The bundled `encode_gif.py` helper orders frames lexically, assigns explicit hold durations, uses an `ffmpeg` palette pipeline, and validates source dimensions plus the encoded frame count, dimensions, duration, and byte limit through `ffprobe`. The workflow stops after returning the verified absolute GIF path; uploading the artifact and mutating a pull request, issue, or document remain separate workflows.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Record continuous video and convert it afterward.** Continuous capture preserves every cursor movement and loading transition but produces larger, noisier artifacts and makes deterministic timing harder. A state storyboard better fits short feature demonstrations where the meaningful evidence is a handful of visible transitions.
|
||||
|
||||
**Keep an inline `ffmpeg` recipe in the skill.** Reconstructing quoting, timing manifests, palette filters, overwrite behavior, and post-encode checks in every run is error-prone. A bundled helper keeps those mechanics executable while the skill owns capture judgment.
|
||||
|
||||
**Include GitHub attachment and description editing.** Upload and remote mutation require separate authentication, confirmation, and recovery rules. Excluding them keeps invocation of a recording skill local and reversible.
|
||||
|
||||
**Use a fixture whenever it is easier to stage.** Fixtures are valid when the requested demonstration is explicitly fixture-backed, but they do not substantiate a real-server or real-API claim. The skill preserves the requested provenance and reports a missing prerequisite instead of silently changing it.
|
||||
|
||||
## Consequences
|
||||
|
||||
Recordings are small, repeatable local artifacts with explicit provenance and a clean repository boundary. The workflow gives up smooth continuous motion, depends on locally available `ffmpeg` and `ffprobe`, and requires the recorder to identify semantic capture points. The helper is exercised against a four-state browser demonstration and invalid duration input; skill shape and repository links are covered by the skill validator and documentation gates.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 浏览器演示 GIF 录制
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-browser-demo-gif-recording.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
浏览器演示一直通过一次性的截取与编码命令制作。这会导致播放节奏和输出大小不一致,容易让录制者选择连续录制,反而掩盖有用的状态变化,还可能模糊真实服务器或 API 流程与 fixture(测试前置数据)之间的界限。将本地录制与附件上传或 PR(Pull Request)编辑合并在同一任务中,还会让本应仅处理媒体的任务获得无关的远程写入权限。
|
||||
|
||||
## 决策
|
||||
|
||||
仓库提供 [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) skill(技能),用于生成本地浏览器演示产物。该 skill 使用当前可用的浏览器控制工作流,先确认请求的流程是真实流程、由 fixture 支撑,还是采用其他模拟方式,再仅在 UI 达到语义上可观察的状态后截取一组精简的分镜帧。帧文件与输出产物默认存放在 Git worktree 之外。
|
||||
|
||||
随附的 `encode_gif.py` 辅助脚本按词法顺序排列各帧,为每帧设置明确的停留时长,通过 `ffmpeg` 调色板流水线编码,并借助 `ffprobe` 校验源图像尺寸以及编码结果的帧数、尺寸、时长和字节上限。工作流在返回已验证的 GIF 绝对路径后即结束;上传产物以及修改 PR、issue 或文档仍属于独立的工作流。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**连续录制视频后再转换。**连续录制能保留每一次光标移动和加载过渡,但会产生体积更大、干扰更多的产物,也更难保持确定的播放时序。状态分镜更适合简短的功能演示,因为有意义的证据只是少数几个可见的状态变化。
|
||||
|
||||
**在 skill 中保留内联 `ffmpeg` 配方。**每次运行都重新组装引号转义、时序清单、调色板过滤器、覆盖行为和编码后检查,容易出错。随附的辅助脚本使这些机制保持可执行,skill 则负责判断何时截取画面。
|
||||
|
||||
**纳入 GitHub 附件上传与描述编辑。**上传和远程修改需要各自独立的身份认证、确认与恢复规则。将它们排除在外,可以使录制 skill 的调用保持本地且可撤销。
|
||||
|
||||
**每当 fixture 更容易布置时就使用它。**当请求明确要求由 fixture 支撑演示时,使用 fixture 是有效的;但它无法为真实服务器或真实 API 的声明提供证据。该 skill 会保持请求指定的演示来源,并在缺少先决条件时报告问题,不会擅自更改来源。
|
||||
|
||||
## 后果
|
||||
|
||||
录制结果成为体积小、可重复生成的本地产物,明确标注演示来源,并与仓库保持清晰边界。该工作流放弃了流畅的连续动态效果,依赖本机提供的 `ffmpeg` 和 `ffprobe`,并要求录制者识别具有语义意义的截取时点。测试使用四状态浏览器演示与无效时长输入检验辅助脚本;skill 的结构及仓库链接由 skill 校验器和文档门禁覆盖。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-portable-required-pull-request-ci.md: 9cf8d97016300c5258c075879176aa6abd64e59e
|
||||
2026-07-23-portable-required-pull-request-ci.zh.md: c6839a133d0c3fe7a699362f6168e17d827a5b61
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Portable pull-request CI recovery boundary
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-portable-required-pull-request-ci.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Required pull-request jobs assigned to organization-owned runner labels remain queued when GitHub cannot allocate those pools. The workflow is valid and standard GitHub-hosted jobs can still pass, but `all checks passed` never starts and an otherwise healthy pull request cannot satisfy branch protection.
|
||||
|
||||
Billing health, a runner definition's `Ready` state, and a large autoscaling ceiling do not prove that a named pool can receive a job. Required correctness checks need a known portable recovery path even when the ordinary low-latency path depends on repository-external runner provisioning.
|
||||
|
||||
## Decision
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
|
||||
|
||||
The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when an enterprise label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
|
||||
|
||||
The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep every required job on standard capacity.** This removes the enterprise allocation dependency, but complete standard-runner jobs give materially slower feedback and still experience shared-capacity queues. The current split retains portable compatibility and serial evidence while spending enterprise capacity on the primary critical path.
|
||||
|
||||
**Select enterprise size from advertised core count.** Benchmarks show non-monotonic scaling and setup variance, so exact complete-job measurements choose the required pools instead.
|
||||
|
||||
**Skip or demote checks while capacity is unavailable.** This would make the status green by dropping evidence rather than by running the repository's required contracts.
|
||||
|
||||
**Use one worker policy on every host.** Outer gate concurrency and inner tool workers contend differently on Linux, Windows, and standard runners; measured host-specific bounds avoid turning additional cores into slower execution.
|
||||
|
||||
## Consequences
|
||||
|
||||
Ordinary pull requests receive lower active runtime at the cost of depending on enterprise configuration and paid rounded minutes. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
|
||||
|
||||
Standard compatibility and serial jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required aggregate green. Recovering availability may require temporarily restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user