Merge remote-tracking branch 'origin/master' into session-query-search

# Conflicts:
#	docs/architecture.i18n.yaml
#	packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
#	packages/session-persistence/session-persistence-jsonl/src/index.ts
#	packages/session-persistence/session-persistence/README.md
This commit is contained in:
Hypatia May
2026-07-23 21:13:29 +08:00
461 changed files with 21288 additions and 6015 deletions
@@ -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:
@@ -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.
@@ -23,6 +23,8 @@ Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into in
The literal types live in the [task data-structure catalog](../../../../docs/core-data-structures/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id.
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.
@@ -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` payloadpart 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` payloadpart 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,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: f21b840493b83c02d7abc3ba1c1bf90635166ec1
2026-07-19-gui-web-client-architecture.zh.md: a50dc556cfc96b6d35feea6ef2b1aadae9f31c44
2026-07-19-gui-web-client-architecture.md: 6e1cbc2d1e3e3437480c8005ca06845c23c628df
2026-07-19-gui-web-client-architecture.zh.md: 9e2b3ef60d97840cd6cbd26e8fdcf922d472391c
@@ -49,9 +49,9 @@ Implementation homes: registry core and the props-share types in `packages/clien
## Services and scope addressing
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-map merges). 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/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
A service is a plugin's only API 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).
@@ -103,16 +103,16 @@ 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 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**: 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)).
@@ -129,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 |
@@ -49,9 +49,9 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-
## 服务与 scope 寻址
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader``ctx.theme``ctx.i18n``ctx.layout`(跨插件视图导航)、`ctx.conversation`send/cancel/views/startSession`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
服务是插件对其他插件的唯一 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` unionregister 同 slots 一样推断注册方注入份额)
slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entrytab 元数据随注册 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(冻结为只读视窗)。
@@ -103,16 +103,16 @@ 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=2import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。
域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 toolviews 样例从契约取 `ToolRowProps`,永不碰 chat 内部)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。
## 怎么开发
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`+ `inject` 拓扑),浏览器半边写在 `src/client/`apply 挂服务/建 store、注册 slot 与 toolview),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。
- **新 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` 加字段并守住引用纪律。
- **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store[slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。
@@ -129,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,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.
@@ -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 魔数,或解码头部;读取原始内容的测试则显式退出压缩。
@@ -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.
@@ -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 策略也无法隐藏这些值。这些都是执行期本地契约的明确属性,并非意外缺口。
@@ -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,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: 1e9bd711e8316e2556fe238eb0a20d76e1d0d5b1
2026-07-22-slot-type-chain-implementation.zh.md: 0eab839d033faac2f2c3356900c7ca1cd69d2dc9
2026-07-22-slot-type-chain-implementation.md: 65b4ebb475fe34d71d8d3a08878b40103b3c95bd
2026-07-22-slot-type-chain-implementation.zh.md: 4c55171ca0118782568e17f349f83d6cf9211617
@@ -34,7 +34,7 @@ ctx.slots.register({
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.
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.
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; a chain registration missing its `select`.
`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").
@@ -43,12 +43,20 @@ Parity rule: **the declaring entry holds the exclusive right to render its child
| 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 |
| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` |
| 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 chain kind: entries self-nominate, first match renders
The fourth `SlotKind`, `'chain'`, inverts routing authority relative to `keyed`: a keyed dispatch site picks its occupant by `entryKey`, while a chain entry nominates itself — the owner dispatches one common currency of owner props and never learns who takes over, so a new takeover package registers with zero owner edits. A chain registration carries a `select` pure selector (`ChainSelect<O, M>`: `(owner) => matched | null`) and an optional `priority` (ascending; ties keep registration = assembly order — the deployment-controllable inject topology — under the same stable sort as list `order`); registering without `select` is one of the loud-at-load cases above. At render, the outlet runs the selectors in chain order: the first non-null return elects its entry and the returned value joins the component's props as `matched` (the component never re-derives its own match), `null` passes the turn to the next entry, and all-null renders the owner's fallback body (`ChainRenderOpts`).
The decline decision lives in `select`, never in a mounted component probing its own props: a component that mounts only to render null still runs its hooks and effects for nothing, and the resulting mount/unmount churn breaks memoization and React key semantics, whereas a selector is a pure function — unit-testable, zero mount side effects — the same discipline as "presentation methods are pure functions of `args`". Purity is the selector's contract: it reads no external mutable state and produces no side effects, so the routing decision is entirely a function of the owner props and safe to run on every dispatch. Selectors route; they never mint — per-dispatch object construction would churn identity every render, so wrapping a matched value in a richer face happens inside the elected component (`useMemo` keyed on `matched`).
In the type chain, a chain entry's SlotMap shape is `{ kind: 'chain'; scope; owner }` with `owner` as the chain's currency; `M` — the `matched` prop's type — is inferred from the select return (a selector narrowing a union member types `matched` automatically), and the component position stays out of `M` inference, the same NoInfer ruling that pins the inject share (rulings below). On the owner side, `renderSlotChain(key, owner, { fallback })` joins `renderSlot` in the `PropsRenderSlots` share, its key domain statically narrowed to the chain-kind keys of the entry's children declaration (`ChainKeysOf`); the dispatch site is one line and holds no derivation or routing logic of its own.
### 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):
@@ -93,7 +101,7 @@ Two hardening decisions in the register signature exist because the obvious alte
## Consequences
Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls. Every props surface is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning.
Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls; for chain slots, WHO renders is additionally a render-time fact, but the deciding selectors are register-site declarations, so the audit surface stays the register calls. Every props surface is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning.
## Alternatives considered
@@ -107,3 +115,5 @@ Render authority is enforceable rather than conventional: who renders what is a
| 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) |
| Keyed dispatch with owner-side routing for takeover slots | The owner accumulates per-entry contracts and a hardcoded routing table (`find` + `entryKey` per takeover); the chain currency keeps new takeover registrations at zero owner edits |
| Components declining by rendering null | Declining requires mounting first — hooks and effects run for nothing, and mount/unmount churn breaks memoization and key semantics; a pure selector decides without a component instance |
@@ -34,7 +34,7 @@ ctx.slots.register({
不存在独立的坑位定义 API。`children` 对象同时做两件事:**把子坑声明出来**,并**授权本组件渲染它们**——坑是渲染树上的一个洞,因为有人要渲染它才存在,所以坑的生命周期就是声明它的 entry 的生命周期(entry 一经 dispose(资源释放),坑随之消亡、坑内既有贡献清空)。children 的值是运行时 spec`kind`/`scope` 驱动 outlet 的迭代形态与 binding 选择;`SlotMap` 是纯类型、运行时即被擦除,这正是键数组形行不通的原因),并与对应 `SlotMap` entry 静态对齐校验——类型与值在同一点声明、交叉验证。
对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下。
对等原则:**声明子坑的 entry 独占渲染这些子坑的权力**,全部在 register 时结清(配置错误在装载时大声失败;渲染热径零校验)。装载即炸的情形:第二个 entry 声明已被声明的坑;向未声明的坑 register;同一个 store 句柄挂到两个 scope 之下chain 注册缺 `select`
`SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。
@@ -43,12 +43,20 @@ ctx.slots.register({
| 份额 | 类型 | 真源 | 内容 |
|---|---|---|---|
| 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` |
| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S |
| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 Schain 键另有 `renderSlotChain` |
| store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) |
凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
### chain kindentry 自荐,首中即渲
第四种 `SlotKind`——`'chain'`——把路由权相对 `keyed` 反转:keyed 的分派现场以 `entryKey` 点选占坑者,chain 则由 entry 自荐——owner 只分派一份通用货币形态的 owner props,永远不知道谁来接管,新的接管包注册进来 owner 零改动。chain 注册携带一个 `select` 纯选择器(`ChainSelect<O, M>``(owner) => matched | null`)与可选的 `priority`(升序;同值保持注册序 = 装配序——部署可控的 inject 拓扑——复用 list `order` 的同一稳定排序);注册缺 `select` 即上文装载即炸情形之一。渲染时 outlet 按链序依次执行各 select:首个非 null 返回值当选,该值以 `matched` 并入组件 props(组件绝不自行重新推导匹配);返回 `null` 则轮到下一个 entry;全 null 则渲染 owner 的 fallback 体(`ChainRenderOpts`)。
「不接」的判定住在 `select` 里,绝不在挂载后的组件里自探 props:组件为了渲染 null 也得先挂载,其 hook 与 effect 全部白跑,随之而来的挂载/卸载抖动还会破坏 memo 化与 React key 语义;而选择器是纯函数——可单测、零挂载副作用——与「presentation methods are pure functions of `args`」是同一条纪律。纯,就是选择器的契约:不读外部可变状态、不产副作用,路由判定因此完全是 owner props 的函数,每次分派都可安全执行。选择器只做路由、绝不铸对象——按分派逐次构造对象会让引用每次渲染都换新;把匹配值包成更丰富的面这件事,发生在当选组件内部(以 `matched` 为依赖的 `useMemo`)。
类型链上,chain entry 的 SlotMap 形状是 `{ kind: 'chain'; scope; owner }``owner` 即链的货币;`M`——`matched` prop 的类型——从 select 返回值推导(选择器收窄 union 成员时,`matched` 类型自动随之收窄),且组件位不参与 `M` 的推断,与钉住 inject 份额的 NoInfer 裁定同源(见下文裁定)。owner 侧,`renderSlotChain(key, owner, { fallback })` 与 `renderSlot` 同住 `PropsRenderSlots` 份额,其键域静态收窄到本 entry children 声明中 chain kind 的键(`ChainKeysOf`);分派现场只有一行,不含任何自有的派生或路由逻辑。
### store 席位:引擎归框架,schema 归注册方
框架拥有恰好一台订阅机械:快照 store 引擎(zustand vanilla + immer + 可选 localStorage 持久化)住 **runtime 包**`./client` 主出口——无子路径),产出裸的可观察源;web-react 在 outlet 处把它们绑定成 hook(按源缓存的 uSES 绑定)。store 里*装什么*是注册方的声明,且必须写成工厂函数,使模块级句柄根本无从存在(模块级句柄会成为跨插件重载存活的事实单例):
@@ -93,7 +101,7 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替
## Consequences
渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。
渲染权威从此可强制执行,而非仅靠约定:谁渲染什么是装载期事实,审计 UI 结构 = 通读 register 调用;对 chain 坑,「谁来渲染」额外多出一层渲染期事实,但做决定的选择器全是 register 现场的声明,审计面仍是 register 调用。每个 props 面都从单一真源静态推导(SlotMap entry、children 键集、store 工厂、inject 返回值),schema 变更由编译器传播,而不靠 grep。插件不再自带任何订阅机械——store 生命周期(每会话实例、dispose、持久化)是钉在 entry 轴上的框架语义。代价:注册选项稠密(children spec 对象);框架背上实打实的推断机械(`defineStore` 的 init/actions 同轮推断可能需要柯里化兜底);编译期双向锁意味着原型阶段的漂移直接是硬错误,而非警告。
## Alternatives considered
@@ -107,3 +115,5 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替
| 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
| 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |
| 接管坑用 keyed 分派 + owner 侧路由 | owner 会不断攒下逐 entry 契约与硬编码路由表(每种接管一份 `find` + `entryKey`);chain 货币让新增接管注册保持 owner 零改动 |
| 组件靠渲染 null 表示不接 | 不接也得先挂载——hook 与 effect 白跑,挂载/卸载抖动破坏 memo 化与 key 语义;纯选择器无需组件实例即可裁决 |
@@ -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
@@ -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.
@@ -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 串行处理也意味着,一个停滞的浮层会阻塞后续模态工作,直至其所有者关闭、中止或卸载该浮层。
@@ -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-23-local-primary-ci-before-push.md: 9d7f4d5a5aae428a003b1cbbb8911e7ad7c50831
2026-07-23-local-primary-ci-before-push.zh.md: 3b58a828bd12d0149d8c4101d8665ee15b9fffc2
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 声明槽、从不声明 keyask-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']` 才排在槽声明之后,这条约定由序缝构造保证正确、但不对第三方静态强制。
@@ -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-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` → 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,11 +24,11 @@ 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 outcome still reaches post-policy and final observers.
- **`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 transform channel; after the waterfall, the registry materializes the complete outcome once before final observation.
- **`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; after the waterfall, the registry materializes the complete outcome once before final observation.
- **`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, 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, 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 NoteCode 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 与安全决策。
@@ -2,7 +2,7 @@
Status: implemented
[Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) restores hook/CI symmetry for the primary Node inventory. [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md) continues to own the pre-commit design.
The hook/CI symmetry in this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md); CI remains the exhaustive enforcement path.
## Problem
@@ -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,7 +2,7 @@
Status: implemented
[Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) now owns the local-hook contract: pre-push selects the same primary inventory as CI. The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands.
The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands.
## Problem
@@ -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,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 及其独立参考流程都必须仍可运行
## 后果
@@ -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
@@ -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.
@@ -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 无法修复的运维依赖
@@ -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-fast-local-git-hooks.md: 4a504c6c4f5816f00be6d86ddfbcb93baf0d7768
2026-07-22-fast-local-git-hooks.zh.md: 792e035bc5761fc044cf231aff3bb59f31a47659
2026-07-22-fast-local-git-hooks.md: a07af1cd424c86f7fa80ea946cd5012362cc66eb
2026-07-22-fast-local-git-hooks.zh.md: 78d4ea8980476609a9140737a75152eba123b308
@@ -4,8 +4,6 @@ Status: implemented
English | [中文](2026-07-22-fast-local-git-hooks.zh.md)
> **Superseded for pre-push:** [Local primary CI before push](2026-07-23-local-primary-ci-before-push.md) replaces the typecheck-only publication checkpoint with the exact primary CI inventory. The fast pre-commit decision remains in force; the pre-push design below records the policy this repository no longer uses.
## Problem
An agent already runs the tests and checks that exercise its change, while commit, push, and CI can each repeat increasingly broad subsets of the same work. A full pre-push suite therefore delays every publication, amplifies unrelated local flakes, and gives no new signal when CI immediately runs the exhaustive matrix again.
@@ -4,8 +4,6 @@ Status: implemented
[English](2026-07-22-fast-local-git-hooks.md) | 中文
> **pre-push 部分已被取代:**[推送前本地运行主 CI](2026-07-23-local-primary-ci-before-push.md)以精确的主 CI 清单取代仅运行类型检查的发布检查点。快速 pre-commit 的决策继续有效;下文的 pre-push 设计记录了本仓库不再采用的策略。
## 问题
agent(智能体)已经会运行能够覆盖自身改动的测试和检查,而提交、推送与 CI 可能分别重复其中范围越来越广的子集。因此,全量 pre-push 套件会拖慢每次推送,放大与当前改动无关的本地偶发失败,而且 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-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 校验器和文档门禁覆盖。
@@ -1,38 +0,0 @@
# Agent Note: Local primary CI before push
Status: implemented
English | [中文](2026-07-23-local-primary-ci-before-push.zh.md)
## Problem
Hosted CI can become unavailable before repository code executes because of account, billing, quota, or runner failures. A typecheck-only publication hook then permits a remote branch update without coverage, snapshot, documentation, build, package, or built-entrypoint evidence precisely when the hosted workflow cannot supply that signal.
Focused checks remain the right feedback loop during implementation, but their selection depends on the author correctly predicting every affected contract. Publication needs one complete, mechanically owned local baseline that does not depend on the hosted control plane starting a job.
## Decision
[lefthook.yml](../../../../lefthook.yml) keeps pre-commit focused on staged lint, whitespace, and vendored-source metadata. Pre-push invokes `pnpm run check:pre-push` and blocks publication on any failure.
The `check:pre-push` package script selects the `pre-push` mode in [scripts/run-gates.ts](../../../../scripts/run-gates.ts). Both `pre-push` and `ci-primary` return the same `ciPrimaryGates()` inventory, so the hook and the primary Node CI job cannot drift through separately maintained command lists. Build consumers retain their explicit scheduler dependencies, and `DSH_GATE_CONCURRENCY` remains the resource-control seam for constrained hosts.
Authors still run focused checks while iterating. They do not run the full aggregate immediately before a normal push because the hook owns that one exhaustive local execution. A hook failure is fixed or reported as a blocker; bypass requires explicit approval.
This contract is equivalent to the keyless primary Node CI aggregate on the current host. It does not claim the supported-version or operating-system matrix, Python SDK, real-provider, native, or sandbox workflow signals that require their own environments.
## Supersedes
This decision supersedes the pre-push half of [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). Its staged pre-commit design remains in force. It also restores the local publication role described by [Parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md) without reviving a second gate inventory.
## Alternatives considered
- **Rely on restoring hosted CI availability** — repairs the immediate administrative failure but leaves publication without a baseline during the next control-plane or runner outage.
- **Wire pre-push to `check:all`** — reuses a broad local command, but that inventory intentionally differs from the primary CI contract and would make “CI equivalent” inaccurate.
- **Copy the CI commands into `lefthook.yml`** — makes the hook visibly comprehensive but creates a second inventory that can drift whenever CI changes.
- **Keep typecheck-only pre-push and require a manual command during outages** — preserves low latency but relies on every author noticing the outage and remembering an exceptional procedure before each update.
## Consequences
Every normal push pays the primary CI aggregate's wall time and may be blocked by a repository-wide local failure unrelated to the outgoing diff. In return, every published revision has observed coverage, snapshots, documentation, build, package, and built-entrypoint evidence from one shared inventory even when hosted jobs never start.
The result is local evidence, not a substitute for unavailable remote environments. Pull requests and handoffs report hosted billing, provider, platform, and pending states separately instead of presenting a successful macOS pre-push run as a green GitHub matrix.
@@ -1,38 +0,0 @@
# Agent Note: 推送前本地运行主 CI
Status: implemented
[English](2026-07-23-local-primary-ci-before-push.md) | 中文
## 问题
托管 CI 可能会因账户、计费、配额或运行器故障,在仓库代码开始执行之前就不可用。此时,仅运行类型检查的发布钩子会允许更新远端分支,却缺少覆盖率、快照、文档、构建、包(package)和构建后入口点的证据;恰恰这时,托管工作流无法提供这些信号。
实现期间,聚焦检查仍是正确的反馈循环,但检查选择取决于作者是否正确预判每项受影响的契约。发布需要一套由机制统一维护的完整本地基线,且不依赖托管控制平面能否启动作业。
## 决策
[lefthook.yml](../../../../lefthook.yml) 让 pre-commit 集中处理暂存文件 lint、空白错误和 vendor 源码元数据。Pre-push 调用 `pnpm run check:pre-push`,任何检查失败都会阻止发布。
`check:pre-push` 包脚本从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `pre-push` 模式。`pre-push``ci-primary` 都返回同一份 `ciPrimaryGates()` 清单,因此钩子与主 Node CI 作业不会因分别维护命令列表而产生漂移。构建产物消费方仍保留对调度器的显式依赖关系,`DSH_GATE_CONCURRENCY` 仍是资源受限主机的资源控制 seam。
作者在迭代时仍运行聚焦检查。正常推送前不立即运行全量聚合,因为钩子负责这一次全面的本地执行。钩子失败必须修复或报告为阻塞项;绕过钩子需要明确批准。
本契约等同于当前主机上的 keyless 主 Node CI 聚合。它不代表已经取得受支持版本矩阵或操作系统矩阵、Python SDK、真实模型提供方、原生构建或沙箱工作流的信号;这些信号需要各自的环境才能取得。
## 取代关系
本决策取代[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)中有关 pre-push 的部分。其中面向暂存文件的 pre-commit 设计继续有效。它还恢复了[并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)中描述的本地发布职责,但没有重新引入第二份门禁清单。
## 考虑过的替代方案
- **依靠恢复托管 CI 可用性**——可以修复当前的管理性故障,但下一次控制平面或运行器中断时,发布流程仍没有基线。
- **将 pre-push 接入 `check:all`**——能够复用一条广泛的本地命令,但其清单有意不同于主 CI 契约,会使「等同于 CI」的表述不准确。
- **将 CI 命令复制到 `lefthook.yml`**——能够直观展示钩子的全面性,但会创建第二份清单,并在每次 CI 变更时产生漂移。
- **保留仅运行类型检查的 pre-push,并要求中断期间手动运行命令**——能够维持低延迟,但依赖每位作者发现中断,并在每次更新前记得执行特殊流程。
## 结果
每次正常推送都要承担主 CI 聚合的实际耗时,也可能被与待推送 diff 无关的全仓本地失败阻塞。相应地,即使托管作业从未启动,每个已发布版本仍有一套由共享清单实际运行得出的覆盖率、快照、文档、构建、包和构建后入口点证据。
该结果只是本地证据,不能代替不可用的远端环境。PR(Pull Request)和交接会分别报告托管服务计费、提供方、平台与待处理状态,而不会把一次成功的 macOS pre-push 运行表述成 GitHub 矩阵已通过。
@@ -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.
@@ -0,0 +1,35 @@
# Agent Note: 拉取请求 CI 的可移植恢复边界
Status: implemented
[English](2026-07-23-portable-required-pull-request-ci.md) | 中文
## 问题
分配到组织自有运行器标签的拉取请求必需作业,在 GitHub 无法为这些池分配运行器时会持续排队。工作流本身有效,GitHub 标准托管作业仍能通过,但 `all checks passed` 始终无法启动,原本健康的拉取请求因此无法满足分支保护要求。
账单状态正常、运行器定义处于 `Ready` 状态以及较高的自动扩缩容上限,都不能证明指定的运行器池可以接收作业。必需的正确性检查需要预先明确一条可移植恢复路径,即使日常低延迟路径依赖仓库外部的运行器预配也不例外。
## 决策
[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e``all checks passed`。企业级运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。
当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。
## 曾考虑的替代方案
**将所有必需作业保留在标准容量上。** 此方案消除了企业级运行器分配依赖,但标准运行器上的完整作业反馈明显更慢,仍会遇到共享容量排队。当前拆分既保留可移植兼容性和串行证据,又将企业级运行器容量用于主关键路径。
**根据标称核心数选择企业规格。** 基准测试表明扩展效果不呈单调变化,设置耗时也存在波动,因此必需运行器池改由完整作业的精确测量结果选定。
**在容量不可用时跳过检查或降低其级别。** 这种方式通过丢弃证据而非执行仓库的必需契约来使状态变绿。
**在每台主机上使用同一工作线程策略。** 外层门禁并发与内层工具工作线程在 Linux、Windows 和标准运行器上的争用方式不同;按主机实测的上限可以避免新增核心反而拖慢执行。
## 后果
普通拉取请求获得更短的活动耗时,代价是依赖企业级运行器配置,并消耗按整分钟取整的付费分钟数。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt``completedAt` 的执行区间分开报告。
企业级运行器分配能力下降时,标准兼容性作业和串行作业仍能提供有用证据,但无法让受阻的必需聚合流程变绿。恢复可用性时,可能需要暂时恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。
@@ -12,7 +12,7 @@ A capability seam ([interface / implementation / consumer](../architecture/2026-
The abstract service declared its operations beyond create/append: `load`, `list`, `has`, `delete`. Production consumers of `ctx.sessionPersistence` use only two: the agent-loop resume path calls `load()` ([packages/core/agent-loop/src/index.ts:176](../../../../packages/core/agent-loop/src/index.ts)), and the ACP bridge calls `list()` for `session/list` ([packages/ui/acp/src/index.ts:494](../../../../packages/ui/acp/src/index.ts)). Grepping every `sessionPersistence.*` / `persistence.*` use across `packages/*/src` and `examples/` finds no `has(` and no `delete(` on the service. The `.has(`/`.delete(` calls in `packages/ui/acp/src/index.ts` are on the in-memory `SessionStore` and a local `Set` of loading ids, not persistence. The only callers of `has`/`delete` were the contract suites and per-backend specs.
`has()` was not just unused it was the most intricate branch in the shared coordinator: a tracked-vs-untracked dual-probe (`loadLive(id, cwd)` for a live-tracked session vs `loadStored(id)` for an untracked one) with a multi-line rationale. `delete()` dragged the `deleteStored` backend hook that every backend had to implement. This is the [drop-mutable-session-summary](2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercised both, but no shipping code asks "is this session persisted?" or removes one.
`has()` was not just unused: it added a tracked-vs-untracked coordinator probe and a contract branch even though `loadStored(id)` already owns durable existence checks. `delete()` dragged the `deleteStored` backend hook that every backend had to implement. This is the [drop-mutable-session-summary](2026-06-19-drop-mutable-session-summary.md) pattern: a contract test exercised both, but no shipping code asks "is this session persisted?" or removes one.
## Decision
@@ -14,7 +14,7 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe
- **dsh-llm / BlockAssembler:** arbitrary chunk streams (valid + malformed: duplicate indices, stragglers, missing block-start). Invariants: `blocks()` count ≤ distinct indices seen; re-assembly idempotent (`blocks()` is stable across repeated calls and `message().content` mirrors it); `blocks()` never throws and yields only valid content-block tags; `finish` reflects the last `finish` chunk, defaulting to `{kind:'stop'}` when none arrives.
- **dsh-session:** arbitrary event logs. Invariants: `deriveMessages` deterministic; replay-from-seed identical; seq strictly monotonic; non-message events never affect derived history; derived content is decoupled from the log.
- **dsh-tools:** arbitrary `SchemaSpec`. Invariants: JSON Schema `required` equals the `required:true` keys at every level; conversion total; **and the composition with [runtime arg validation](../architecture/2026-06-11-runtime-arg-validation.md)** — generated args satisfying a spec pass `validateArgs`, and targeted corruptions (dropped required key, non-object top level) are rejected. This closes the validator/`InferArgs` drift risk.
- **dsh-tools:** arbitrary `ParameterSchemaSpec`. Invariants: JSON Schema `required` equals the `required:true` keys at every level; conversion is total for valid declarations; **and the composition with [runtime arg validation](../architecture/2026-06-11-runtime-arg-validation.md)** — generated args satisfying a spec pass `validateArgs`, and targeted corruptions (dropped required key, non-object top level) are rejected. Focused cases cover every value root, exact-one overlap/no-match, explicit openness, raw defaults, and lossy JSON. This closes the compiler/validator/`InferArgs` drift risk.
- **dsh-agent-loop:** arbitrary send schedules against a never-exhausting adapter, driven through the `agent/status` settle signal (no wall-clock sleeps). Invariants: no message lost; turn numbers strictly increase; status transitions stay on the legal machine.
## Consequences
@@ -9,7 +9,7 @@ The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-sea
- **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): at the decision point, every real provider declared `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) built `{ prompt, parent, signal?, agentOptions? }` and structurally could not set either; `structured` appeared only in the scripted test fixture. The service's capability check carried two assert rows whose only exercisers were the rejection tests.
- **`SubagentRun.sendMessage` / `SubagentRun.resume`** (same file): implemented by NO provider — not even the mock; the spawn spec asserts their *absence*.
The only reason `dsh-subagent` depends on `dsh-tools` at all is `outputSchema`'s `SchemaSpec` type. Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer.
The only reason `dsh-subagent` depended on `dsh-tools` at the decision point was `outputSchema`'s schema type (now `ObjectJsonSchema`). Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer.
## Proposal
+10 -16
View File
@@ -1,11 +1,11 @@
---
name: dsh-pre-push-checks
description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select focused implementation evidence and preserve the mandatory primary-CI pre-push gate.
description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select the smallest tests and checks that cover the outgoing diff without reflexively running the full repository suite.
---
# DSH Pre-Push Checks
Use this skill to run relevant implementation evidence once and the complete local publication baseline once before a `deepseek-harness` push. Pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push invokes `pnpm run check:pre-push`, which selects the same primary Node inventory as `pnpm run check:ci`. Hosted CI still owns platform- and provider-specific evidence.
Use this skill to run relevant local evidence once before a `deepseek-harness` push. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix.
## Inspect the outgoing change
@@ -27,15 +27,15 @@ If the branch has no upstream or that range is not meaningful for the stack, com
## Select relevant evidence
Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression. Run that evidence while iterating; the hook supplies the universal publication baseline.
There is no universal local baseline beyond the hooks. Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression; add broader checks only for surfaces the diff actually reaches.
- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to pre-push unless the change is genuinely cross-cutting or the user requests it earlier.
- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it.
- **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it.
- **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output.
- **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke.
- **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets.
Do not manually repeat a passing check merely because commit or push follows. In particular, do not run `check:pre-push` immediately before a normal push and then repeat the same aggregate in the hook.
Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook.
### Focus unit coverage on the affected source
@@ -60,19 +60,13 @@ pnpm exec vitest related packages/<group>/<package>/src/<changed>.ts \
`vitest related` cannot discover behavior reached only through configuration, dynamic loading, subprocesses, workers, built artifacts, or external providers; select those owning tests explicitly. Do not use `--passWithNoTests`, lower coverage thresholds, or narrow `--coverage.include` merely to hide an uncovered affected file. If a selected package scope fails because one focused test does not cover it, add its other relevant owning tests or narrow the source scope only when the excluded modules cannot be affected by the change.
## Mandatory publication gate
## Full local rehearsal
The normal push runs the complete keyless primary Node inventory through Lefthook:
```sh
pnpm run check:pre-push
```
Invoke the command directly only when the user requests a rehearsal independent of publication or when diagnosing the hook itself. Add the relevant `pnpm run test:e2e` target when credentials are available and behavior depends on a real provider; real-API e2e is not part of the keyless primary inventory.
Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate.
## Handle failures
If a relevant check or the publication gate fails, stop and fix or explain the blocker. Do not push and hope CI differs.
If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs.
If a failure looks environment-specific, prove it:
@@ -83,9 +77,9 @@ If a failure looks environment-specific, prove it:
## Push procedure
1. Run the selected focused checks once during implementation.
1. Run the selected relevant checks once.
2. Commit normally and inspect any files changed by the pre-commit fixer before continuing.
3. Push normally so the complete primary Node hook runs once.
3. Push normally so the incremental typecheck hook runs.
4. Verify the remote ref matches local `HEAD`.
```sh
@@ -1,4 +1,4 @@
interface:
display_name: "DSH Pre-Push Checks"
short_description: "Run focused and primary CI checks before push"
short_description: "Run the relevant DeepSeek Harness checks before push"
default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch."
@@ -0,0 +1,53 @@
---
name: record-browser-gif
description: Record browser or Web UI interaction demos as optimized local GIFs using the available built-in browser, state-based frame capture, and deterministic encoding. Use when Codex is asked to make, record, or generate a GIF that demonstrates a browser workflow, including real-server or real-API behavior. Stop after returning the verified local artifact; do not upload it or edit a pull request.
---
# Record Browser GIF
Produce a short, truthful UI demonstration as a local GIF. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size.
## Keep the boundary explicit
- Produce frame images and one local `.gif` artifact only.
- Never upload the artifact, post a comment, or change a pull request, issue, or document under this skill. Hand those actions to a separate workflow if the user requests them.
- Preserve the requested provenance. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture.
- Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt.
## Record the flow
1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required.
2. Resolve the evidence boundary before recording: identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports.
3. Choose three to six states that tell one story, such as initial, typed, submitted, and completed. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer.
4. Keep one viewport and crop for every frame. Store frames in an absolute artifact directory outside the Git worktree unless the user requests another location, and name them lexically: `00-initial.png`, `01-typed.png`, and so on.
5. Before each screenshot, wait for a concrete UI condition such as a unique label, enabled control, changed document title, or completed response. Do not use a fixed delay as proof that the application reached the state.
6. Capture no secrets, personal data, unrelated tabs, or transient notifications. Stop any unnecessarily long real-API run after the demonstrated state is visible.
Use the browser's own screenshot API. When it returns image bytes, save those bytes directly; the encoder detects image content independently of the filename extension.
## Encode the GIF
Require `python3`, `ffmpeg`, and `ffprobe`. If either media binary is missing, report the dependency instead of installing software without authorization.
Set `GIF_SKILL_DIR` to this skill's absolute directory, then encode the lexically ordered frames:
```sh
python3 "$GIF_SKILL_DIR/scripts/encode_gif.py" \
/absolute/path/to/frames \
/absolute/path/to/demo.gif \
--durations 1.5,1.5,1.5,3.5 \
--fps 10 \
--max-width 1200 \
--colors 128
```
One duration applies to every frame; otherwise provide one comma-separated positive duration per frame. The encoder rejects fewer than two frames, mismatched dimensions or durations, invalid limits, accidental overwrite, unexpected duration, and output above `--max-bytes`.
For a large artifact, reduce `--max-width` first, then `--colors` or `--fps`; retain readable text and the final state long enough to inspect. Use `--force` only after resolving the exact output path.
## Verify and deliver
1. Read the encoder's JSON summary and confirm the output path, source and encoded frame counts, dimensions, duration, and byte size.
2. Inspect the first and final source frames and the resulting GIF. Confirm that the transition is legible, the last state is held long enough, and no sensitive content appears.
3. If capture occurred near a repository, run `git status --short` and confirm the artifact did not dirty the worktree.
4. Return the absolute GIF path, render it when the client supports local media, and state whether the recording used a real API, fixture, or another transport. Stop without uploading it or editing remote content.
@@ -0,0 +1,4 @@
interface:
display_name: "Record Browser GIF"
short_description: "Record and optimize local browser demo GIFs"
default_prompt: "Use $record-browser-gif to record this browser flow as a verified local GIF."
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""Encode lexically ordered browser screenshots into a verified GIF."""
from __future__ import annotations
import argparse
import json
import math
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import NoReturn
DEFAULT_MAX_BYTES = 5 * 1024 * 1024
def fail(message: str) -> NoReturn:
"""Exit with a concise user-correctable error."""
raise SystemExit(f"error: {message}")
def positive_float(value: str) -> float:
"""Parse one finite positive command-line number."""
try:
parsed = float(value)
except ValueError:
fail(f"expected a number, got {value!r}")
if not math.isfinite(parsed) or parsed <= 0:
fail(f"expected a positive finite number, got {value!r}")
return parsed
def positive_int(value: str) -> int:
"""Parse one positive command-line integer."""
try:
parsed = int(value)
except ValueError:
fail(f"expected an integer, got {value!r}")
if parsed <= 0:
fail(f"expected a positive integer, got {value!r}")
return parsed
def parse_durations(value: str, frame_count: int) -> list[float]:
"""Expand one hold duration or validate one duration per source frame."""
parts = [part.strip() for part in value.split(",")]
if not parts or any(not part for part in parts):
fail("--durations must be a number or a comma-separated list of numbers")
durations = [positive_float(part) for part in parts]
if len(durations) == 1:
return durations * frame_count
if len(durations) != frame_count:
fail(f"--durations supplied {len(durations)} values for {frame_count} frames")
return durations
def require_binary(name: str) -> str:
"""Resolve a required media binary or fail without attempting installation."""
path = shutil.which(name)
if path is None:
fail(f"required binary {name!r} is not available on PATH")
return path
def run_json(command: list[str]) -> dict[str, object]:
"""Run a media probe and parse its JSON object."""
try:
completed = subprocess.run(command, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as error:
detail = error.stderr.strip() or error.stdout.strip() or str(error)
fail(detail)
try:
value = json.loads(completed.stdout)
except json.JSONDecodeError as error:
fail(f"media probe returned invalid JSON: {error}")
if not isinstance(value, dict):
fail("media probe returned a non-object JSON value")
return value
def probe_stream(ffprobe: str, path: Path) -> dict[str, object]:
"""Read the first video stream's dimensions and timing metadata."""
result = run_json(
[
ffprobe,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,nb_frames,duration,r_frame_rate",
"-of",
"json",
str(path),
]
)
streams = result.get("streams")
if not isinstance(streams, list) or len(streams) != 1 or not isinstance(streams[0], dict):
fail(f"expected one video stream in {path}")
return streams[0]
def stream_int(stream: dict[str, object], key: str, path: Path) -> int:
"""Read a positive integer stream field."""
try:
value = int(stream[key])
except (KeyError, TypeError, ValueError):
fail(f"missing integer {key!r} in media probe for {path}")
if value <= 0:
fail(f"non-positive {key!r} in media probe for {path}")
return value
def ffconcat_quote(path: Path) -> str:
"""Quote an ffconcat path while preserving literal backslashes."""
value = str(path)
if "\n" in value or "\r" in value:
fail(f"frame path contains a newline: {path}")
return "'" + value.replace("'", "'\\''") + "'"
def write_concat_manifest(path: Path, frames: list[Path], durations: list[float]) -> None:
"""Write an ffconcat manifest that materializes the final frame's hold."""
lines = ["ffconcat version 1.0"]
for frame, duration in zip(frames, durations):
lines.append(f"file {ffconcat_quote(frame)}")
lines.append(f"duration {duration:.6f}")
lines.append(f"file {ffconcat_quote(frames[-1])}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def build_parser() -> argparse.ArgumentParser:
"""Build the command-line contract."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("frames", type=Path, help="directory containing lexically ordered frames")
parser.add_argument("output", type=Path, help="output .gif path")
parser.add_argument("--pattern", default="*.png", help="frame glob within the input directory")
parser.add_argument(
"--durations",
default="2",
help="one hold duration or one comma-separated value per frame",
)
parser.add_argument("--fps", type=positive_int, default=10, help="encoded frames per second")
parser.add_argument(
"--max-width",
type=positive_int,
default=1200,
help="maximum output width",
)
parser.add_argument(
"--colors",
type=positive_int,
default=128,
help="palette colors, from 4 through 256",
)
parser.add_argument(
"--max-bytes",
type=positive_int,
default=DEFAULT_MAX_BYTES,
help="maximum output size",
)
parser.add_argument("--force", action="store_true", help="replace an existing output file")
return parser
def main() -> None:
"""Validate inputs, encode the GIF, verify it, and print a JSON summary."""
args = build_parser().parse_args()
frame_dir = args.frames.resolve()
output = args.output.resolve()
if not frame_dir.is_dir():
fail(f"frame directory does not exist: {frame_dir}")
if output.suffix.lower() != ".gif":
fail(f"output must end in .gif: {output}")
if output.exists() and not args.force:
fail(f"output already exists (pass --force to replace it): {output}")
if not 4 <= args.colors <= 256:
fail("--colors must be between 4 and 256")
if args.fps > 30:
fail("--fps must not exceed 30")
frames = sorted(path.resolve() for path in frame_dir.glob(args.pattern) if path.is_file())
if len(frames) < 2:
fail(f"expected at least two frames matching {args.pattern!r} in {frame_dir}")
if output in frames:
fail("output path must not match an input frame")
durations = parse_durations(args.durations, len(frames))
expected_duration = sum(durations)
ffmpeg = require_binary("ffmpeg")
ffprobe = require_binary("ffprobe")
dimensions = {
(stream_int(stream, "width", frame), stream_int(stream, "height", frame))
for frame in frames
for stream in [probe_stream(ffprobe, frame)]
}
if len(dimensions) != 1:
fail(f"all frames must have identical dimensions, got {sorted(dimensions)}")
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="record-browser-gif-") as temporary:
manifest = Path(temporary) / "frames.ffconcat"
write_concat_manifest(manifest, frames, durations)
scale = f"scale='min({args.max_width},iw)':-2:flags=lanczos"
palette = f"palettegen=max_colors={args.colors}:stats_mode=full"
filters = (
f"fps={args.fps},{scale},split[base][palette_input];"
f"[palette_input]{palette}[palette];"
"[base][palette]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle"
)
command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-f",
"concat",
"-safe",
"0",
"-i",
str(manifest),
"-vf",
filters,
"-loop",
"0",
"-t",
f"{expected_duration:.6f}",
"-y" if args.force else "-n",
str(output),
]
try:
subprocess.run(command, check=True)
except subprocess.CalledProcessError as error:
fail(f"ffmpeg failed with exit code {error.returncode}")
stream = probe_stream(ffprobe, output)
width = stream_int(stream, "width", output)
height = stream_int(stream, "height", output)
encoded_frames = stream_int(stream, "nb_frames", output)
try:
actual_duration = float(stream["duration"])
except (KeyError, TypeError, ValueError):
fail(f"missing duration in media probe for {output}")
tolerance = max(0.2, 2 / args.fps)
if abs(actual_duration - expected_duration) > tolerance:
fail(f"expected about {expected_duration:.3f}s, encoded {actual_duration:.3f}s")
if width > args.max_width:
fail(f"expected width at most {args.max_width}, encoded {width}")
if encoded_frames < 2:
fail(f"expected an animated GIF, encoded {encoded_frames} frame")
byte_size = output.stat().st_size
if byte_size > args.max_bytes:
fail(f"output is {byte_size} bytes, above --max-bytes {args.max_bytes}")
print(
json.dumps(
{
"output": str(output),
"sourceFrames": len(frames),
"encodedFrames": encoded_frames,
"width": width,
"height": height,
"durationSeconds": actual_duration,
"fps": args.fps,
"bytes": byte_size,
},
indent=2,
sort_keys=True,
)
)
if __name__ == "__main__":
main()
+172 -64
View File
@@ -27,20 +27,15 @@ env:
jobs:
# One large runner pays hosted setup once, then the repository scheduler
# overlaps the complete unsharded primary Node inventory. Build starts eagerly;
# only consumers of emitted output wait for it.
# Three enterprise jobs isolate coverage, static analysis, and the
# build-backed consumer tail. The static job publishes its exact build so
# consumers do not repeat the longest part of their critical path.
node-24:
if: github.event_name == 'pull_request'
runs-on: dsh-ubuntu-24-04-96core
name: node 24 / complete
runs-on: dsh-enterprise-ubuntu-latest-32core-test
name: node 24 / static
env:
DSH_COVERAGE_MAX_WORKERS: '16'
DSH_ESLINT_CACHE: '1'
DSH_ESLINT_CONCURRENCY: '16'
DSH_GATE_CONCURRENCY: '10'
DSH_PUBLINT_CONCURRENCY: '16'
DSH_SNAPSHOT_MAX_CONCURRENCY: '8'
DSH_GATE_CONCURRENCY: '8'
steps:
- uses: actions/checkout@v6
with:
@@ -55,23 +50,56 @@ jobs:
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Enable corepack and install dependencies
run: |
corepack enable
pnpm install --frozen-lockfile
- name: Run static gates
run: pnpm run check:ci:static
- name: Pack built tree
run: >-
tar -czf "$RUNNER_TEMP/node-24-built-tree.tar.gz"
apps/*/lib packages/*/*/lib vendor/*/lib
- uses: actions/upload-artifact@v6
with:
name: node-24-built-tree
path: ${{ runner.temp }}/node-24-built-tree.tar.gz
if-no-files-found: error
retention-days: 1
compression-level: 0
node-24-coverage:
if: github.event_name == 'pull_request'
runs-on: dsh-enterprise-ubuntu-24-04-32core-test
name: node 24 / coverage
env:
DSH_COVERAGE_MAX_WORKERS: '24'
DSH_GATE_CONCURRENCY: '8'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/cache/restore@v4
with:
path: .cache/eslint
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
path: /home/runner/.local/share/pnpm/store/v11
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
- name: Select preinstalled Node, install dependencies, and prepare bubblewrap
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Enable corepack, install dependencies, and prepare bubblewrap
run: |
node_root="$(printf '%s\n' "$RUNNER_TOOL_CACHE"/node/"${PRIMARY_NODE_VERSION}".*/x64 | sort -V | tail -n 1)"
if [[ ! -d "$node_root" ]]; then
echo "preinstalled Node ${PRIMARY_NODE_VERSION}.x not found in $RUNNER_TOOL_CACHE" >&2
exit 1
fi
echo "$node_root/bin" >> "$GITHUB_PATH"
export PATH="$node_root/bin:$PATH"
[[ "$(node --version)" == "v${PRIMARY_NODE_VERSION}."* ]]
corepack enable
pnpm install --frozen-lockfile &
install_pid=$!
@@ -84,14 +112,110 @@ jobs:
if (( install_status != 0 )); then exit "$install_status"; fi
exit "$sandbox_status"
- name: Run complete unsharded primary Node CI concurrently
run: pnpm run check:ci
- name: Run exhaustive coverage
run: pnpm run check:ci:coverage
node-24-consumers:
needs: node-24
if: github.event_name == 'pull_request'
runs-on: dsh-enterprise-ubuntu-latest-32core-test
name: node 24 / snapshots and artifacts
env:
DSH_ESLINT_CACHE: '1'
DSH_ESLINT_CONCURRENCY: '8'
DSH_GATE_CONCURRENCY: '8'
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
DSH_PUBLINT_CONCURRENCY: '8'
DSH_SNAPSHOT_MAX_CONCURRENCY: '32'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/download-artifact@v8
with:
name: node-24-built-tree
path: ${{ runner.temp }}
- name: Restore built tree
run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz"
- uses: actions/cache/restore@v4
with:
path: /home/runner/.local/share/pnpm/store/v11
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
- uses: actions/cache/restore@v4
with:
path: .cache/eslint
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Enable corepack, install dependencies, and prepare bubblewrap
run: |
corepack enable
pnpm install --frozen-lockfile &
install_pid=$!
bash scripts/prepare-ci-bubblewrap.sh &
sandbox_pid=$!
install_status=0
wait "$install_pid" || install_status=$?
sandbox_status=0
wait "$sandbox_pid" || sandbox_status=$?
if (( install_status != 0 )); then exit "$install_status"; fi
exit "$sandbox_status"
- name: Run compatibility, snapshot, and artifact gates
run: |
pnpm run check:ci:lint &
lint_pid=$!
pnpm run check:node-compat &
compat_pid=$!
DSH_EXAMPLE_MODE=lib pnpm run test:snapshot &
snapshot_pid=$!
pnpm run publint &
publint_pid=$!
pnpm run verify-node-next-types &
node_next_pid=$!
pnpm run verify-built-package-invariants &
built_invariants_pid=$!
DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts \
examples/headless-agent/tests/keyless-smoke.e2e.ts \
examples/tui-agent/tests/tui-keyless-smoke.e2e.ts \
packages/examples/cli-demo/tests/built-bin.e2e.ts \
packages/examples/acp-demo/tests/built-bin.e2e.ts \
packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts \
packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts \
packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts &
built_bin_pid=$!
final_status=0
capture_status() {
local child_status=0
wait "$1" || child_status=$?
if (( final_status == 0 && child_status != 0 )); then
final_status=$child_status
fi
}
for child_pid in \
"$lint_pid" "$compat_pid" "$snapshot_pid" \
"$publint_pid" "$node_next_pid" "$built_invariants_pid" "$built_bin_pid"
do
capture_status "$child_pid"
done
exit "$final_status"
node-compat:
if: github.event_name == 'pull_request'
# Distinct larger-runner pools avoid both standard-runner setup outliers and
# delayed allocation when independent environment contracts share one pool.
# Each compatibility contract receives an independent standard hosted job.
runs-on: ${{ matrix.runner }}
name: ${{ matrix.name }}
env:
@@ -103,12 +227,12 @@ jobs:
include:
- node: '22.19'
name: node 22.19
runner: dsh-ubuntu-24-04-4core
gate_concurrency: '2'
runner: ubuntu-latest
gate_concurrency: '1'
- node: 26
name: node 26
runner: dsh-ubuntu-24-04-32core
gate_concurrency: '2'
runner: ubuntu-latest
gate_concurrency: '1'
steps:
- uses: actions/checkout@v6
@@ -137,7 +261,7 @@ jobs:
python-sdk:
if: github.event_name == 'pull_request'
runs-on: dsh-ubuntu-24-04-8core
runs-on: ubuntu-latest
name: python 3.10 / keyless SDK
steps:
- uses: actions/checkout@v6
@@ -154,50 +278,34 @@ jobs:
run: uv run --python 3.10 --group test --project python/sdk pytest
# One Windows box shares setup across the required build/site checks and the
# complete observational portability inventory. run-gates reports failures
# from observational gates without allowing them to fail the required job.
# observational portability inventory. Linux owns duplicate lint, coverage,
# and snapshots so they do not dominate the paid Windows critical path.
windows:
if: github.event_name == 'pull_request'
runs-on: dsh-windows-2025-32core
runs-on: dsh-enterprise-windows-2025-32core-test
name: windows node 24 / complete
env:
# Keep ESLint itself single-threaded: 16 ESLint workers took 174 seconds on
# this image. The outer scheduler still overlaps lint with the other gates.
DSH_COVERAGE_MAX_WORKERS: '12'
DSH_ESLINT_CACHE: '1'
DSH_GATE_CONCURRENCY: '16'
DSH_PUBLINT_CONCURRENCY: '16'
steps:
- uses: actions/checkout@v6
- uses: actions/cache/restore@v4
with:
path: .cache/eslint
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
- name: Enable Developer Mode (symlink support)
shell: pwsh
run: >-
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
# Extracting the many-file pnpm store cache is slower on this image than
# a clean parallel install, and saving it adds more latency after gates.
- name: Select preinstalled Node and install (immutable)
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
# Extracting the many-file pnpm store cache is slower than a clean install,
# and saving it adds more latency after gates.
- name: Enable corepack and install (immutable)
shell: pwsh
run: |
$nodeRoot = Get-ChildItem -Path "$env:RUNNER_TOOL_CACHE\node" -Directory |
Where-Object { $_.Name -like "$env:PRIMARY_NODE_VERSION.*" } |
Sort-Object { [version]$_.Name } |
Select-Object -Last 1
if ($null -eq $nodeRoot) {
throw "preinstalled Node $env:PRIMARY_NODE_VERSION.x not found in $env:RUNNER_TOOL_CACHE"
}
$nodeBin = Join-Path $nodeRoot.FullName 'x64'
if (-not (Test-Path $nodeBin -PathType Container)) {
throw "preinstalled Node x64 directory not found at $nodeBin"
}
Add-Content -Path $env:GITHUB_PATH -Value $nodeBin
$env:PATH = "$nodeBin;$env:PATH"
if ((node --version) -notlike "v$env:PRIMARY_NODE_VERSION.*") {
throw "selected unexpected Node version $(node --version)"
}
corepack enable
pnpm install --frozen-lockfile
@@ -579,7 +687,7 @@ jobs:
all-checks-passed:
name: all checks passed
runs-on: ubuntu-latest
needs: [node-24, node-compat, python-sdk, windows]
needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows]
if: always() && github.event_name == 'pull_request'
steps:
- name: Fail if any needed job did not succeed
+2 -2
View File
@@ -73,10 +73,10 @@ When required `gh`, `pnpm`, build, test, or generator commands fail because the
### Run relevant checks locally
Agents MUST run focused checks while implementing; normal pre-push runs `check:pre-push`, the same primary Node inventory as `check:ci`. Select focused evidence with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run.
Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run.
- Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior.
- Let the hook run the full aggregate once; never bypass a failure without explicit approval. Remote CI owns the platform and provider matrix.
- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change.
- `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)).
## Secrets / .env
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 27774fd3e0ffc821e7287f6153906a5d21530dc2
README.zh.md: b7d08f2bc948d0a6d388dd672c5a702bc7da6f6f
README.md: 8b3a46081503ac9a29cc791cc302066e33e0f995
README.zh.md: c73e2c70119d5b82d4629041a7a16848f7acbe92
+1 -1
View File
@@ -25,7 +25,7 @@ The installer clones DeepSeek Harness to `~/.dsh/source`, links `dsh` into `~/.l
For the recommended local interface, build the frontend after installation and after each update, then start the Web UI:
```sh
pnpm --dir ~/.dsh/source run build:web
pnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web
dsh web
```
+1 -1
View File
@@ -25,7 +25,7 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m
推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI:
```sh
pnpm --dir ~/.dsh/source run build:web
pnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web
dsh web
```

Some files were not shown because too many files have changed in this diff Show More