From b6037a652336374e8ce6a7796631131a4e71196b Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Wed, 14 Oct 2026 00:11:53 +0800 Subject: [PATCH] docs(compatibility): rewrite as a staged, evidence-anchored rule set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compress the compatibility page from narrative prose to a uniform grammar — numbered rules with bold-lead invariants, Limitations, Checks and Procedures, each carrying evidence pointers into the dsh source tree — staged by lifecycle: supported set, admission, fold, metering parity, client surface, projection cache, packaging, verification. Drop what code, tests or git history already carry: the per-bump stateVersion narratives, the V4 shape table, per-route architecture stories and retired-version deltas. AGENTS.md gains the conciseness mandate for the page: less is always better. --- AGENTS.md | 2 +- docs/compatibility.md | 196 ++++++++++++++++-------------------------- 2 files changed, 74 insertions(+), 124 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d4b043e..3592d76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ Complete ALL of these before ANY commit: ## Compatibility - Important! - MUST install and work correctly on every supported `@deepseek-ai/dsh` release, with no regressions in runtime dependencies, message parsing, or any user-visible behavior. -- **`docs/compatibility.md` is the single source of truth** — supported releases, durable-log generations, fold-by-shape and optional-seam rules, parsing resilience for untrusted log/projection data, low-level parity with the harness's own metering, and the baseline gates. Read it BEFORE any compatibility-relevant change, and keep it current whenever the code or the supported set changes. +- **`docs/compatibility.md` is the single source of truth** — supported set, admission gates, fold rules, metering parity, client seams, projection-cache protocol, packaging pins, and the verification gates. Read it BEFORE any compatibility-relevant change, and keep it current whenever the code or the supported set changes. Keep it minimal — less is always better: record only decisions and declared limitations that no other artifact carries, with evidence pointers into the dsh source tree; never narrate what code, tests, or git history already say. - Consider updating dependencies to the latest version where possible — deepseek-harness evolves rapidly. ## I18n diff --git a/docs/compatibility.md b/docs/compatibility.md index a1fc1c3..90d2d27 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,158 +1,108 @@ # Compatibility -dsh-context declares per-release compatibility with `@deepseek-ai/dsh` in its package manifest (`dsh.compatibility.dshReleases`). This page records what is actually verified for each declared release, and how. +The rules dsh-context must hold on every supported `@deepseek-ai/dsh` release, and the evidence for each. Supported releases are declared in `package.json` (`dsh.compatibility.dshReleases`); baselines and their per-line seam faces are pinned in `tests/baselines.ts`. -Last verified: **2026-10-13** (plugin `dsh-context@0.69.1` source tree, dsh `0.2.0-rc.2`). The disposable-profile column below records its own, earlier manual dates. +**Grammar.** Entries are numbered **rules** (the bold lead is the invariant), plus **Limitation** (an accepted gap), **Check** (how a claim is proven), and **Procedure** (how to run a compatibility task). Evidence rides in trailing parens — paths in this repo, or `dsh:` paths in the harness tree read at the baseline tag — or as a table directly beneath the rule it maps. A rationale appears only where code cannot show it. -## Supported dsh releases +**Law.** Record only what no other artifact carries; delete what code, tests, or git history already say. Verify against the dsh sources before acting — source wins over this page. Less is ALWAYS better. -| dsh release | Session log | Declared | Automated seam matrix | Disposable-profile install / uninstall | -| --- | --- | --- | --- | --- | -| `0.1.7-rc.2` | V4 | compatible | ✅ baseline `v0.1.7-rc.2` | ✅ install OK → uninstall OK (verified manually 2026-09-28) | -| `0.2.0-rc.2` | V4 | compatible | ✅ baseline `v0.2.0-rc.2` | ✅ install OK → 1 composed row → uninstall OK → 0 rows (`dsh-context@0.60.0`, verified manually 2026-09-29) | +Last verified: **2026-10-13** (dsh-context `0.69.1`, dsh `0.2.0-rc.2`). -The automated seam matrix runs for every row on every `pnpm test` in CI: both workflows fetch the pinned baseline tags and build the plugin first, and a missing prerequisite fails the run instead of skipping it. On a dev box with no checkout it skips with a printed reason. The disposable-profile column is a manual, per-release check: each release's CLI was installed from npm into a temporary `DSH_HOME` (the real `~/.dsh` is never touched) — `0.1.7-rc.2` and `0.2.0-rc.2` on 2026-09-28 and 2026-09-29. The check pins the plugin build it installs: a bare-name install resolves through the profile's package manager, whose release-age policy can lag a just-published build (on `0.2.0-rc.2` the bare name resolved to `0.59.1`, so the row records the explicit `dsh-context@0.60.0`). The only pnpm complaints there are peers the harness supplies from its own runtime module tree — none version-incompatible, and the install is never refused. +## 1 · Supported set -The supported range is the two lines above: the `0.1.7` line from `rc.2` (the first release of that line with a complete npm dependency closure; `rc.2` adds `startsSeries` to the first `request/header` of a resumed series and the `developer/message` tool-registry events; `startsSeries` is inert to the fold, while registry messages contribute to injected context), and the `0.2.0` line from `rc.2` — the continuation of the V4 Config-form generation, every seam the plugin rides spelled as at `0.1.7-rc.2` (cordis `4.0.4`, the same projection-registry contract, platform module table, client seats, settings transport, and durable-event vocabulary; still the V4 log generation, with no `v4-to-v5` migration in the session-format tree). Everything older — the `0.1.5` line (supported through `dsh-context@0.65.x`), the `0.1.2`/`0.1.3` generations, the `0.1.1` line, and the alpha previews — is no longer in the support matrix; on such a harness the plugin's baseline gate composes the fallback units and shows the upgrade prompt instead of folding. +*What the plugin declares it works on.* -## Harness-side compatibility admission (0.1.7+) +1. **Support exactly `0.1.7-rc.2` and `0.2.0-rc.2`** — both the V4 durable-log generation. The floor is `0.1.7-rc.2` because it is the first `0.1.7`-line release with a complete npm dependency closure; anything older gets §2's fallback composition with an upgrade prompt, never a fold. -Every supported line enforces a plugin's own `peerDependencies` at startup and install: a bundle whose `@deepseek-ai/dsh*` peer ranges do not satisfy the running version (semver, `includePrerelease: true`) is skipped at composition, refused at install, and only loads with an exact-version exemption (`dsh plugin allow-version`). This plugin's dsh peers are all `>=0.1.7-rc.2`, which both supported lines satisfy, so the plugin composes normally with no exemption. The gate predates them (`0.1.7` introduced it), so an older harness without it (the `0.1.5`/`0.1.6` lines) still loads the plugin — and the plugin's own baseline gate below is what composes the fallback units there. +## 2 · Admission -## Baseline gate +*Whether the plugin loads at all.* -The declared floor is enforced at runtime, not only documented. At startup the host resolves the running harness's version — first from the module tree the plugin is bound to, then from the `$DSH_HOME/profiles/node_modules` mirror — and compares it against `0.1.7-rc.2` (channel order: release > rc > beta > alpha): +**Harness-side** -- **Below the floor** — fallback projection units that fold nothing and serve zeroed data: the Context and Fleet tabs keep their (empty) cards while a modal names both versions and urges the update. -- **At or above the floor** — the real projection units. -- **Undetectable or unparseable** — **fails open** into the real units, so a probe misfire never blanks a working deployment. +1. **dsh ≥ `0.1.7` enforces a plugin's `@deepseek-ai/dsh*` peer ranges** at startup and install (semver, `includePrerelease: true`; exemption `dsh plugin allow-version`). This plugin's peers are all `>=0.1.7-rc.2` — no exemption on any supported line. (dsh: `packages/boot/app-boot/src/plugin-compatibility.ts`, `apps/cli/src/plugin.ts`) -The mirror reflects whichever installation last booted a CLI profile, which need not be the running one (a packaged desktop client never refreshes it), so it never outranks the running module tree. Implementation: `src/host/version.ts`, `src/host/fallback.ts`, `src/shared/version.ts`. +**Plugin-side** -## Session-log generation +2. **Enforce the floor again at runtime** — a pre-`0.1.7` harness has no gate and would still load the plugin. Below the floor: zeroed fallback units plus an upgrade modal. Version undetectable: FAIL OPEN into the real units — a probe misfire must never blank a working deployment, and the home mirror (last-booted profile) never outranks the running module tree. (`src/host/version.ts`, `src/host/fallback.ts`, `src/shared/version.ts`) +3. **Feature-detect `.volatile()` at schema build time**, so the same bundle loads — preferences inert — on a below-floor harness whose schemastery predates it. (`src/host/config.ts`) -Every supported line is the V4 durable-log generation, and the plugin folds it from one shape-driven code path (`src/host/logShapes.ts` holds the vocabulary readers — `firstTokenTimeOfStream`, `replaceRangeOf`, `isTokenChunk` — and `applyTimeline` runs a single code path over them): +## 3 · Fold -| Seam | V4 (`0.1.7-rc.2+`, `0.2.0-rc.2+`) | +*Durable events → projection state, host-side.* + +**Shapes** + +1. **One shape-driven path; never branch on a detected harness version.** (`src/host/fold.ts`, `src/host/logShapes.ts`) +2. **Trust the migration guarantee; carry no legacy readers.** Every read path the plugin rides (live open, persistence handles, `sessionQuery.observeSession`) migrates pre-V4 logs to V4; an unconvertible log is refused with `SessionFormatUnsupportedError`, never served raw. Pre-V4 spellings exist nowhere in this repo. (dsh: `packages/session/session-format-v3-to-v4`, `packages/session/session-format-catalog`, `packages/session/session-persistence/src/errors.ts`, `packages/session-query/session-query/src/documents.ts`) +3. **`developer/message` folds into injected context** — request composition, live surface, archives; never human inputs; an empty-content event keeps its surface node at zero tokens where the harness's `deriveEventMessage` projects null. (`src/host/fold.ts`) +4. **Read tool-result errors from `message.isError` alone** — the generation admits event-level `data.error` only alongside it; a hostile envelope-only row degrades to unflagged, never a throw. (`src/host/fold.ts`) + +**Totality** + +5. **Folds are TOTAL** — unknown event types return state unchanged; a malformed event drops whole (no partial state); no `undefined`-valued property is ever materialized into persisted state. The projection registry drives `apply` with no error boundary: one throw stalls the unit's cells and push feed forever, and one `undefined` property fails EVERY projection-cache write for the session. + +- **Limitation — image offload (deferred):** on `IMAGE_OFFLOAD_REQUIRED` the harness offloads images via a message projection; the fold reads raw durable events, so an offloaded message keeps its pre-offload image count and price until compaction removes it. Revisit only if it shows in practice. + +## 4 · Metering parity + +*Figures the plugin prints that the harness also prints.* + +1. **Mirror, don't import.** Re-fit when a supported line moves an implementation; verify differentially against the harness's own projections (§8). Right-hand paths are `dsh:` tree paths. + +| Plugin site | dsh source mirrored | | --- | --- | -| System prompt | `system/message` surface node (a `request/header` carrying `header.system` is rejected outright) | -| First token | embedded `assistant/message.data.stream` (also `assistant/attempt.data.stream`) — packed delta runs plus raw `chunk` records | -| Replacement endpoints | `{ startSeq, endSeq }` | -| Nested PTC dispatch | `tool/ptc-dispatch` | -| Tool result | first-class `role: 'tool'` message with lifted `message.toolCallId`/`message.isError`, direct content | -| Developer messages | `developer/message` tool-registry changes, nested under `data.message` | +| `src/shared/estimate.ts` | `packages/llm/token-meter/src/estimate.ts` — `estimateSystemMessage` and its per-block text arm | +| `src/shared/fileTokens.ts` | `packages/llm/llm/src/content.ts` `fileHandleText` — a `file` block costs its request handle text, never its bytes (priced via `packages/llm/token-meter/src/route-pricing.ts`); price the pathless arm | +| TPS chip first-token anchor | `packages/llm/llm/src/assistant-stream.ts` `assistantStreamFirstTokenTime` | +| `src/client/headline.ts` `occupancyPercent` | `packages/client/ui-conversation/src/client/context-occupancy.ts` — internal to the kernel bundle, no package export, hence mirrored | +| `src/client/reserve.ts` | `packages/compaction/compaction-basic/src/config.ts` — only the shipped defaults (`thresholdRatio` 0.8, `headroomTokens` 65536): preset realms make a deployment's policy unreadable; when `contextWindow − reservedOutput − headroomTokens` is non-positive the backend refuses (`TargetPressureConfigError`), so the bar paints no masks | -**Legacy logs never reach the fold.** The running harness migrates every historical log to V4 on EVERY read path the plugin rides — live open, persistence read handles, `sessionQuery.observeSession` (the detail/backfill cold reads) — in memory on read opens and as a published successor file on write opens; the fork-inherited prefix migrates with the rest (the V3→V4 stage lifts the old `tool-result` wrapper into the first-class message and rewrites sources). A log the migration chain cannot convert is refused entirely (`SessionFormatUnsupportedError`): the session is unavailable, never served raw. So on any supported harness the events the fold sees are V4-shaped by construction, and the pre-V4 spellings (the `tool-result` wrapper block and its `isError`, the event-level `data.error` mark, and further back the `header.system` envelopes, `assistant/chunk` floods, `{ start, end }` endpoints, `tool/code-dispatch`) exist nowhere in the code. +- **Limitation — declared divergence:** the timing card's wait/generation boundary opens at the first OBSERVABLE instant (the first token, or an earlier `block-start` marker the token packing could not stamp), not at the meter's first-token anchor — so marker-tiled decode buckets and the split never double-count one window. -The fold never branches on a detected harness version. The version probe is best-effort — it reports the module tree the embedding shell resolves plugin imports to, which a packaged client may redirect — so it decides which units register and nothing more. +## 5 · Client surface -### Deltas of the current generation +*What the browser half may rely on.* -- **Developer messages**: `developer/message` tool-registry changes are nested under `data.message` and fold into injected context, including request composition, live surface, and removed-node archives. They never count as human inputs; empty-content events keep their surface position at zero tokens, matching the harness's message projection (its `deriveEventMessage` projects an empty message to null — the fold's structural read keeps the node). -- **Tool-result error mark**: the event-level `data.error` metadata is admitted only alongside the message mark (the generation's own validation refuses a row whose `data.error` outlives `message.isError`), so the fold reads `message.isError` alone; a hostile row carrying only the envelope field degrades to unflagged, never to a throw. -- **Image offload accounting (deferred)**: when a route rejects with `IMAGE_OFFLOAD_REQUIRED`, the harness offloads images out of the context through a message projection. The plugin's fold reads raw durable events, so an offloaded message keeps its pre-offload image count and price until compaction removes it — an over-count on a rare recovery path, not a crash. Revisit alongside the harness's own token-meter treatment if it shows up in practice. -- **User preferences**: plugin configuration lives on the Plugins page, derived from the loader entry's own Config schema (`src/host/config.ts`): cordis validates the `config:` block through the schema's Standard Schema face, and the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts). The browser half registers its card on the Plugins page's keyed `plugins.bundle.config` seat over the `configForms` transport (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). The `.volatile()` modifier is feature-detected at schema build time (`volatileField`), so the same bundle still loads — preferences inert — on a below-floor harness whose schemastery predates it (the gate's fallback composition). - - Preference values persisted on a retired line (the `settings.yaml` section `dsh-context`) are moved into the profile entry of the same name by the harness's own legacy import on first boot, so existing values carry into the current surface. -- **System-prompt prefix/suffix injection**: the `promptPrefix`/`promptSuffix` preferences are joined around the harness's assembled system prompt by two globally registered prompt sections (`src/host/promptInjection.ts`), not by string-editing session events. The seams this rides, probed per supported line in the compat matrix: - - `ctx.systemPrompt.section()` — the ordered-section registry face (`packages/core/system-prompt`). The sections are registered through a deferred `ctx.inject(['systemPrompt'], …)`, so a composition that starts the registry later still gets them, one without it degrades to a no-op, and the registration unwinds with this plugin's fiber. The orders anchor on the harness's own registry (`HARNESS_IDENTITY - 1000`, `DEPLOYMENT_PERSONA_SUFFIX + 1000`), keeping the prefix first and the suffix last even if the central order table is renumbered. - - The volatile-only commit — `vendor/loader/src/config/entry.ts` rewrites the running config references on a preference edit without remounting the entry. The section text providers read those references per assembly, so an edit applies from the next model step; the harness's own system-prompt projection then updates the session log (`system/message` replace or append, per its routing), which is how a new session carries the text from its first turn. - - Scope: global sections participate in every assembly — every session, subagents included (the same semantics as the harness's own deployment `personaPrefix`/`personaSuffix`). A deployment that registers a `complete` prompt section keeps ONLY that section, by the registry's design; the configured text is dropped there. The text is registered `interpolate: false`, so `{{...}}` in user text stays literal and can never trip the strict variable interpolation. +**Seams** -## Parsing resilience [IMPORTANT!] +1. **Every capability rides an OPTIONAL seam** — deferred `ctx.inject` (never a hard `inject`), the face re-proved before use, the registration guarded, the module declared in `dsh.client.inject`. No service → no capability: no pending fiber, no throw. Per-line faces are pinned in `tests/baselines.ts` and probed by §8's matrix. +2. **Mount at most once per page** (issue #137) — the client-plugin runner force-assigns a fresh shadowing priority to every registration, so the slot registry's same-priority duplicate guard never fires across a double mount. Read the raw ledger; mount nothing when `context-jump` is already seated; the read fails open. (dsh: `packages/client/ui-renderer/src/client/registry.ts` `SlotRegistry.entries`; probe `ledger`) +3. **Session jump uses the sidebar's own verb**, `uiWorkspace.openSession(SessionTarget)` — never the sessions service's `open(id)`, removed by the `0.1.6` selection refactor (issue #90). (dsh: `packages/client/ui-workspace/src/client/navigation.ts`; probe `sessionNav`) -The plugin lives off data it does not own: the durable session log (event shapes vary across dsh versions, producers, and hand-edited replays), the conversation snapshot behind a client join, projection payloads on the wire, history RPC pages, and persisted stores. All of it is untrusted input at every layer, and two failure classes are designed out: parsing that blanks the page (the error card), and anything that leaves the page stuck on "loading". +**Boundary** -- **One bad record never takes down a view.** A malformed node, event, tool entry, or file op degrades to zero rows for that item — the card, the tab, and the session keep working. -- **Host-side projection folds are TOTAL.** The harness projection registry drives `apply` straight off the session/event bus with no error boundary of its own: one throwing fold stalls that unit's cells and its `session/projection` push feed, and the browser waits on "loading" forever. Unknown event types return the state unchanged; per-event processing is isolated so a malformed event is dropped whole (all-or-nothing, no partial state); and no `undefined`-valued property is ever materialized into persisted state — the plain-JSON precondition makes one such property fail EVERY projection-cache write for the session, breaking sessions in unrelated, far-away places. -- **Client-side parsing degrades visibly.** Delivered projection payloads are sanitized at the boundary (the `timelineOf` pattern: collections re-proved, scalars zeroed, whole-value absence stays `null` → loading screen); per-item work in any fold over join/log data is isolated (per-item guards, or a bounded catch where a hostile object may throw on property access); every async fetch resolves to data or a visible retryable state, never an unhandled rejection that leaves a spinner. -- **A session with no pushed `contextTimeline` value never stalls the view.** The projection store's `seed()` clears a key its baseline omits, and a checkpoint row can be missing or version-stale on a cold-observed session — so the Context tab, the Fleet tab, or the `/context` modal can sit with no value at all and no push on the way. The source opens the detail channel itself in that case (rev 0): the plugin's own `/api/dsh-context/detail` route folds the durable log on demand and serves the slim head beside the collections, the next pushed head takes the channel back per its own revision, and a settled-without-data read surfaces the retryable failure note (the same `DetailNote` the detail cards use) instead of an eternal spinner. The Fleet tab reads that one source too, so its page carries the same guarantee, and every figure on it is derived by the same helper the Context tab's card uses (`agentSelfOf`), so the two mounts cannot disagree. -- **A refold invalidates earlier detail reads.** When the pushed revision decreases, the client resets its detail ledger and ignores any earlier read's data, absence, or failure. Once that read settles, the pending revision is fetched again; collections from the old fold never merge into the new head. -- **Every field is re-proved at runtime.** Structural narrowing over blind casts; optional chaining over non-null assertions; elements that fail the shape are skipped instead of throwing. -- **Every parser carries hostile fixtures next to its happy path**: wrong types, null/missing fields, null or primitive elements inside arrays, unpaired references, and objects that throw on property access. The repository's 100% per-file coverage bar applies to every guard branch — an untested guard is an unverified promise. +4. **Re-prove everything at the boundary** — collections re-proven, scalars zeroed, whole-value absence stays `null` (the loading screen); structural narrowing over blind casts; skip elements that fail the shape instead of throwing. (`src/client/timelineSource.ts`) +5. **Isolate per-item work over hostile objects**; every async read resolves to data or a retryable failure note — never an eternal spinner. (`src/client/timelineSource.ts`) +6. **A pushed revision decrease resets the detail ledger** — stale reads never merge into the new head. (`src/client/timelineSource.ts`) -## Low-level parity +- **Limitation — complete prompt sections:** a deployment registering a `complete` system-prompt section keeps ONLY that section (registry design); `promptPrefix`/`promptSuffix` are dropped there. (dsh: `packages/core/system-prompt`) -The plugin's low-level logic mirrors the newest supported dsh implementation: token estimation re-fits `dsh-token-meter`'s `estimateSystemMessage` (`estimateSystemTokens` / `estimateSystemContent`) and its per-block text arm (`estimateTextTokens`, shared with the fold's message price), a file attachment's price re-fits `dsh-llm`'s `fileHandleText` as that request projection prices it (see the attachment-card rule), and the TPS chip's first-token rule matches `dsh-llm`'s `assistantStreamFirstTokenTime`. The Context card's occupancy percentage is the composer ring's own derivation — `@deepseek-ai/dsh-client-ui-conversation`'s `contextOccupancy` (`Math.min(100, Math.round(used / window * 100))`, `used` = `contextPressure.projectedTokens ?? pressureTokens`) — read off the same token-meter projection and printed on a 0.1 grid, so the card and the ring can never disagree on the value and only the printed precision differs (the ring reads an integer, the card one decimal). The kernel's helper is internal to its browser bundle with no package export, so the plugin mirrors the formula (`occupancyPercent` in `src/client/headline.ts`) instead of importing it; where the meter's projection key is absent altogether (an older line), the card falls back to its own derived anchor. The Current Context bar's two auto-compaction reserve masks mirror `dsh-compaction-basic`'s own pressure trigger the same way (`src/client/reserve.ts`): `thresholdTokens = floor(min(contextWindow × thresholdRatio, contextWindow − reservedOutput − headroomTokens))`, with the session's durable window (`request/context`'s `contextWindow`) and routed output cap (`request/header`'s materialized `config.maxTokens` — the envelope value the backend's own `reservedCompletionTokens` reads) as the inputs, and that package's two shipped defaults (`thresholdRatio` 0.8, `headroomTokens` 65,536) as the policy: the web composition mounts the backend inside each agent preset's isolated realm, so no root-level plugin can read a deployment's configured policy, and only those defaults are mirrored — and because the backend REFUSES (throws `TargetPressureConfigError`, disabling pressure compaction) rather than clamps when `contextWindow − reservedOutput − headroomTokens` is non-positive, the bar reserves nothing and paints no masks for such a window instead of implying a trigger at zero. The timing card's wait/generation boundary deliberately diverges from that rule: it opens at the first OBSERVABLE instant — the first token, or an earlier `block-start` marker the token packing could not stamp (a redacted reasoning block leaves no chunk behind) — so the marker-tiled decode buckets and the wait/generation split read one window and the legend's shares never double-count it. When a supported line moves these implementations, the plugin's copies are updated to the newest one — the fold's statistics are differentially verified against the harness's own projection values on real logs (see "What each check means"). +## 6 · Projection cache -## Web client seams +*How fold-state changes reach existing installations.* -The browser half rides generation-specific seats, each reached through an optional seam so a deployment without the service simply goes without the capability. Capabilities only newer lines serve are reached through a deferred `ctx.inject` — never a hard module `inject` — so a composition without the service composes fully with the capability absent (no pending fiber, no throw): the service shape is re-proved before use, the registration is guarded, and the module is declared in `dsh.client.inject`. Every supported line ships the right Sidebar and the keyed `main` conversation panel; the plugin contributes to whichever face the running line serves: +1. **Bump a unit's `stateVersion` iff later events cannot backfill the change** — running totals, per-step collections flushed at `step/end`, materialized envelopes. Serve-time-derivable fields need no bump, and new wire fields are additive-optional: a pre-field row serves without them and the client degrades per §5's boundary rules. Current versions sit at each unit's `stateVersion` site; past bumps live in git history. +2. **Stale rows refold from the durable log**; the warm-up probes BOTH `contextActivity` and `contextTimeline` and cold-refolds missing or stale rows on first page open, so idle sessions rebuild exactly. (`src/host/backfill.ts`) +3. **Every cold read passes the host-wide gate** (issue #121) — FIFO, one at a time, only while the V8 heap sits below two-thirds of its ceiling; a skipped read takes the caller's designed no-data path, never a failure. (`src/host/coldRead.ts`) -| Seam | V4 (`0.1.7-rc.2`, `0.2.0-rc.2`) | -| --- | --- | -| Conversation panel root | keyed `main` panel's `main.conversation` (the plugin's Conversation-view tabs hang under it: Context (`order` 20), and Fleet (30) when `defaultPlacement` serves tabs and the `fleetTab` preference is not hidden) | -| Insight page (panel + entry) | the layout's keyed `main` slot under the `dsh-context` panel key, with the sidebar entry on the `sidebar.panellist` list under the same id — the shell owns the entry row and panel switching; both spellings probed per baseline (the `insightPage` probe) | -| Right Sidebar tab + guide entry | `sidebarRightTabs` registry, `sidebar.right.pane.tab`(`.title`) seats, guide capsule with `order`/`title`/`description`/`icon` (an optional `commandId` on guide entries and `bindCommands` on tab actions are additive and unused by the plugin) | -| Preferences card seat | `plugins.bundle.config` over the `configForms` transport | -| Session jump | `uiWorkspace.openSession(target: SessionTarget)` — the sidebar row click's own verb, its parameter re-spelled by the 0.1.6 selection refactor; the sessions service carries no selection verb any more | +- **Procedure — force one session's refold:** `rm ~/.dsh/storages/session_projcache/sessions/.json` — a derived cache, rebuilt from the durable log. -The guide capsule carries `order`, `title`, `description`, and `icon` (the `guideEntry` probe in `tests/baselines.ts` pins the fields of the supported generation). +## 7 · Packaging -**One live mount per page (the second-mount guard, issue #137).** The same package can mount twice into one page's slot ledger — a static profile bundle plus a later activation of the same package. The client-plugin runner force-assigns a fresh shadowing priority to every registration, so the registry's own same-id duplicate guard (which fires only at the same priority) never fires across the two mounts, and the conversation header — which renders the raw ledger and matches the active view by entry id — would paint both copies of every list-slot entry: two Context tabs, both styled active, a duplicated React key. The client `apply` therefore reads the raw ledger first (`SlotRegistry.entries`, probed per baseline as the `ledger` seam) and mounts nothing when this bundle's chat→Context jump entry (`conversation.chat.assistant-actions` id `context-jump`, which no preference gates) is already seated. The read fails open — an absent, shape-shifted, or hostile ledger face reports unseated and the mount proceeds, so an older line or a foreign face can never blank a working mount. Where the guard does not fire, the harness's own duplicate-namespace check on the dictionary registration already refuses the second mount. +*Dependency declarations that look dead and are not.* -The File Activity card's file-name affordance is an optional-column story: the plugin opens the file's preview tab through `ctx.sidebarRight.openResource` (the built-in Files sidebar's own path), building the `dsh-resource://file/…` address with the harness's browser-safe `fileAddressFor` (inlined by the client bundle, exactly as the Sidebar's own file types inline it). Where the preview face is absent (or refuses the address), the name keeps its original system-open behavior; the `sidebar.nav` probe pins the navigation face's spelling. +1. **Declare `@deepseek-ai/dsh-client-ui-primitives`' bare imports as this plugin's own build/test requirements** — its `lib/index.js` imports `shiki`, `@shikijs/langs/*`, `katex`, the `mdast-util-*`/`micromark-*` chain, `anser`, `clsx`, `diff`, and `simple-icons` while listing them as its own devDependencies. The client bundle never carries them (`deps.onlyBundle` + the `dsh-client-bundle-purity` plugin, `tsdown.config.ts`). If a primitives release starts inlining them, drop the matching entries in the same change. +2. **Declare `@deepseek-ai/dsh-client-store`, `@deepseek-ai/dsh-util-code-language`, and the store's own `zustand` + `immer`** — same reason, reached only through the primitives dist. +3. **Never add `tailwindcss` as a direct dependency** — `@tailwindcss/node` pins it at the same version; a second resolving copy breaks the napi native binding (`src/client/styles/tailwind.css`). -The Context browser's attachment cards are another optional-column story. A message's `image` and `file` blocks render as one ordered card list, and a click hands the attachment to the harness's preview column through the same `previewAddressOf` → `openResourceVia` path the File Activity card rides. The path itself comes from the plugin's own `/api/dsh-context/attachment` fetch route (same authenticated `/api` fence, Connection exact-route registry, and cold-read gate as the detail and fleet routes): the route answers only an id the SESSION'S OWN log cites (a payload walk bounded in depth and total over hostile input, so an id no logged content block carries — the harness's own gateway is stricter still, reading declared fields per event type — resolves to nothing) and answers the store's named alias. A verbatim file already has one (`attachments.fileHostPath` → `/attachments/v1/files///`); a normalized image does not — its object is digest-named and suffixless, while the preview column dispatches its viewer by suffix — so the route publishes (or reuses) a named hard link under `/cache/dsh-context/previews///` and answers that. The link shares the immutable, digest-verified object bytes; the cache is disposable, and a deployment whose attachment store is not host-file-backed, or whose session log cannot be read, answers a typed `null` and the card reports "preview unavailable" instead. Image thumbnails keep riding the harness's own session-authorized `uiConversation.imageUrl` loader; the file-type glyph resolves defensively off the primitives module (`FileTypeGlyph`), and every card degrades to its metadata when a face is absent. A card's token row is the REQUEST's price, never the attachment's size: an image row is DeepSeek's own vision calculator on the sent dimensions, and a file row is the handle the model actually receives. dsh projects every `file` block to `fileHandleText` at request assembly (`dsh-llm/content.ts` — no provider takes a file block natively), and the token meter prices that text in place of the block's structural JSON price (`dsh-token-meter/route-pricing.ts`), so a file costs a fixed handle whatever its byte size. The plugin mirrors that sentence verbatim and prices it with the meter's own text arm (`estimateTextTokens`), from the three facts a log already carries: name, byte size, and the digest's first eight characters. The handle's stored-copy path is execution-world state no log carries (`llm.fileRequestText` resolves it live through the attachment store and the fs mapping), so the card prices the pathless arm — the one the harness emits when the environment cannot read the copy — and, like every other token row, reports it as an `≈` estimate. A reference the log proves no name or no byte size for prices nothing and shows no row, exactly like its size row. The fold keeps the meter's FIXED structural price for that same block, which is what `estimateMessage` charges without a route projection; the card answers the request's question and the fold the surface's, so the two figures differ by design, as they do in the harness. +## 8 · Verification -The session jump — a Context Insights session card or an Agent network node bringing its session forward — rides the harness's own selection verb (`openSessionVia` in `src/client/services.ts`): `uiWorkspace.openSession`, the verb the built-in sidebar's row click uses on every supported line. The face is re-proved per call and a deployment serving none (or a hostile face) swallows silently — the verb's own navigation returns the center column to the conversation (`selectPanel(null)`), so a dead jump simply stays on the page. Issue #90 was exactly the retired-spelling trap: the plugin once called the sessions service's own `open(id)`, which the 0.1.6 selection refactor removed from the service (the click then did nothing on `0.1.7-rc.2`), so the jump follows the view owner and the per-baseline `sessionNav` probe pins the verb's signature and the retired spelling's absence. +*How every claim above is proven.* -The Context Insights page's DeepSeek balance capsule is an all-optional stack, so it arms nowhere it cannot: the host reaches the `settings` and `credentials` services only inside a deferred `ctx.inject` (and re-reads them per request, so a key added while the host runs is picked up on the next open), mounts `/api/dsh-context/balance` through the same Connection exact-route registry as the detail and backfill routes, and the outbound `/user/balance` read carries the key only inside the host process. The provider's settings row is read off the Config-form `describe()` projection — the row is the entry whose served value declares the top-level `apiKeyEnv` credential ref, preferred under the settings ids the generations have served it as (`llm-deepseek` on product profiles, `llm-deepseek-api-key` elsewhere). The capsule appears only on a live, well-formed answer; a deployment whose DeepSeek API-key provider is not composed (no settings row), whose credentials service cannot resolve the key, whose connection service lacks the exact-route registry, or whose platform read fails (network, non-2xx, malformed payload, a third-party endpoint that does not serve the balance path) answers a typed `null` and the browser renders nothing — no pill, no spinner, no error state. The `balance` probe pins the settings face and the provider row's sources per baseline. - -## Dependency constraints tied to a dsh release - -Most of the plugin's dependencies are read directly from source, so an ordinary unused-dependency audit names them correctly. These are the exceptions: each looks dead to a static scan, and dropping it breaks the `client` vitest project (or the bundle) rather than the code that appears to use it. - -- **`@deepseek-ai/dsh-client-ui-primitives` publishes its heavy runtime libs as bare imports.** Its `lib/index.js` imports `shiki`, `@shikijs/langs/*` (every language), `katex`, the `mdast-util-*` / `micromark-*` markdown chain, `anser`, `clsx`, `diff`, and `simple-icons` by specifier even though it lists them as its own `devDependencies` — it inlines nothing. They are therefore **this plugin's** build- and test-time requirements, declared here so the primitives' `lib/` resolves under `vite`'s transform. The client bundle itself never carries them: `MarkdownText` and the other imported primitives are the only reachable surface, and `tsdown.config.ts` asserts the boundary with `deps.onlyBundle` (`@opencode-ai/models`, `@deepseek-ai/dsh-util-workspace-path`, `simple-icons`) plus the `dsh-client-bundle-purity` plugin. If a future primitives release starts inlining these, drop the matching entries here in the same change. -- **`@deepseek-ai/dsh-client-store` and `@deepseek-ai/dsh-util-code-language`** are imported by that same `lib/index.js`, which is why they are declared even though no plugin source names them. `dsh-client-store` in turn imports `zustand` and `immer` bare, so those two are declared for the same reason — they are the store's runtime, reached only through the primitives dist. -- **`tailwindcss` is deliberately NOT a direct dependency.** `@tailwindcss/node` depends on it at that same exact version, so the one-copy pin the native-binding rule requires (`src/client/styles/tailwind.css`) already holds. Adding a top-level entry would risk a second copy resolving in the build process, which is exactly what breaks the napi calls. - -## What each check means - -- **Automated seam matrix** — part of this repository's `pnpm test` (the `compat` vitest project). For every baseline tag it stages the harness's REAL sources at that tag, boots the plugin's built host entry into that tag's actual `SessionProjectionRegistry` on the cordis release the line vendors, and probes the tag's client seams (slots, finalized-nodes seat, image loader, history face/envelope, markdown chrome, platform module table, that generation's durable-event vocabulary, the right Sidebar tab seam, its guide-entry contract and its resource-navigation face, the session-jump seam, the preferences card seat and settings transport). Definitions live in `tests/baselines.ts` — the one source of truth for the supported baselines and their per-version seam faces; both workflows fetch the pinned baseline tags before testing, and the `compat` project fails rather than skips when they are absent. Retired seams are asserted ABSENT (`settings.register`, the settings `get(ns)` read, the sessions service's `open(id)`), so a resurrection names the seam instead of silently shadowing the current face. Every event family `src/host/fold.ts` switches on is read back out of that source and asserted to be declared by every baseline, so a new fold case cannot go unprobed. To add a future dsh release: add one `Baseline` entry (tag, vendored cordis, faces) to `tests/baselines.ts` and its tag to both workflows' fetch step, then run `pnpm test` and re-fit whichever seam a failing probe names. The `compat` project boots the BUILT plugin (`pnpm run build` first) against the ACTUAL harness sources at each baseline tag and needs a dsh checkout (env `DSH_REPO`, default `~/dev/deepseek-harness`). -- **Statistics against the harness's own folds** — the plugin's figures were verified differentially against the harness's OWN projection values (`sessionStats`, `contextBreakdown`, `contextPressure`, `tokenUsage`) over real session logs: system/tools/message tokens, per-request counts, turns/steps, TTFT, generation, tool time, and every billed cost bucket match exactly. -- **Disposable-profile install / uninstall** — for each release, that exact `dsh` CLI version was installed from npm into a temporary `DSH_HOME` (the real `~/.dsh` is never touched), then: - 1. `dsh plugin --profile add dsh-context` — install OK; - 2. `dsh --profile --dump-config` — the bundle's `- id: dsh-context` row composes into the effective configuration (exactly 1 row); - 3. `dsh plugin --profile remove dsh-context` — uninstall OK, dump-config back to 0 rows. - -## Scope - -These checks prove source-level seam compatibility, statistical parity with the harness's own folds, and disposable-profile install/start-composition/uninstall per release. They are not a claim of full web-app runtime acceptance on a real Profile — visible UI behavior depends on the harness generation and the browser half, which the seam matrix approximates from the tag's sources. - -## Upgrading from an older plugin build - -The skill-injection category (issue #66) bumps the timeline projection's `stateVersion` (18 → 19): skill content — the `` catalog digest, a user-explicit `/name` invocation's instructions message, and the content a `skill`-tool load returns — now folds into its own `skill` composition bucket instead of `inject`/`tool`. The re-bucketing changes the fold's per-category sums, so on upgrade every cached per-session row is invalidated and re-folded from the durable log, which rebuilds them under the new categories. The bump orphans the `contextTimeline` key for idle sessions, which have no refresh channel until they go live again — such a session re-folds when its log receives its next event. New sessions are exact from their first event. - -The Agent network card covers the orphaned relatives without waiting for a visit: a session listed without a `contextTimeline` row (composition absent, occupancy only) fetches its slim head from the plugin's `/api/dsh-context/detail` fetch route (mounted through Connection's exact-route registry, behind the same authenticated `/api` fence), which folds the durable log on demand — the node's composition bar fills in completely the first time the card opens (on the Fleet tab, or inline in the right Sidebar's Context panel). The same backfill serves sessions whose cache predates the plugin's installation entirely. Where the route is unavailable (the baseline gate, a connection service without the exact-route registry) — or the cold-read gate below skips the read under heap pressure — the card degrades to the pressure-only occupancy fill as before. Sibling order follows the parent row's own `subagentCatalog` projection (parent catalog event order — the same sequence the harness header's subagent popup lists) whenever the list block's projection cache carries it; a deployment or baseline without the catalog projection keeps the running-first/updatedAt heuristic, and children absent from a cached catalog sort after its entries under that heuristic. A card's work state rides the same signal the header popup's own state dot does: `subagentTiming.lastTurnCompleted` (folded since `0.1.7-alpha.1`, so on every supported line) marks a subagent's last closed turn finished, and a running session is never done either way. The list block's row carries no completion flag at all — `SessionSummary` has never had one — and a top-level session never carries a `subagentTiming` projection, so a card that is not a subagent falls back to its own folded step count against a stopped session. That fallback is a proxy, not the harness's own statement: it cannot tell a turn that ended from one the user stopped, and a session with no steps yet draws no state glyph at all. - -The session cards' last-user-message preview (`lastUser` on the `contextTimeline` head) bumps the timeline projection's `stateVersion` (19 → 20): a stale row can never gain the field while its session is idle (it only lands when a user message folds), so on upgrade every cached timeline row is invalidated and refolded from the durable log. To keep the bump from orphaning idle sessions' rows, the warm-up (`src/host/backfill.ts`) probes BOTH insight rows — `contextActivity` AND `contextTimeline` — and cold-refolds every session whose either row is missing or version-stale, so previews (and every other head figure) are exact for the whole session list the first time the page opens after the upgrade. A deployment without the cold-path services — or whose client cannot mount the warm-up's trigger route — degrades to the per-session refold on the session's next activity, the same as pre-warm-up upgrades. - -The Context Insights page (the sidebar panel list's first-level `main` panel) reads its `contextActivity` projection — a per-day ledger of billed tokens and completed requests, `stateVersion` 1 — off every session-list row's projection column. A new key is purely additive: no existing cached row is invalidated, but sessions folded before the unit existed (and sessions that predate the plugin entirely, whose `contextTimeline`/`contextHeaders` rows are missing too) have nothing to serve until they next go live. The plugin therefore runs a one-pass warm-up on demand (`src/host/backfill.ts`): the page is the rows' only reader, so it POSTs the plugin's `/api/dsh-context/backfill` fetch route (the same authenticated `/api` fence as the detail route) the first time it mounts, and only then does the host walk the corpus — for every stored session missing the row it cold-reads the durable log once through the projection cache's own cold-read ladder (`coldSnapshot`), which seeds each unit from its cached rows, folds the remainder, and writes the refreshed checkpoint back. The pass runs once per host process (later opens and later POSTs are no-ops); live sessions are skipped (they fold for themselves); a legacy log the running harness refuses to migrate is permanent — its per-session detail logs at debug and the pass ends with one summary info line, so an unmigratable corpus costs one line per page-open process, not one warn per session per boot; and a deployment whose client never opens the page never cold-reads a thing. A deployment without the sessionQuery/sessionProjectionCache/sessionPersistence/sessions services — or whose connection service lacks the exact-route registry — simply never arms the pass (such a client could not open the page to read the rows anyway). - -Every cold read the plugin makes — this pass's per-session fold and the detail route's cold rung alike — runs through one host-wide cold-read gate (`src/host/coldRead.ts`, issue #121): reads run one at a time (FIFO), and a read starts only while the process's V8 heap sits below two-thirds of its own ceiling (the persistence backend decodes a whole log per read, so an unbounded fan-out of 10k-event sessions stacks several-hundred-MB object graphs on a heap whose ceiling may not move — a capped Electron main). A read the gate skips under pressure takes the caller's designed no-data path rather than failing: the pass stops early with one info line (folded rows persist; the rest refold on their next live activity or a later process's pass), and the detail route answers its typed null. - -The page's per-day usage chart (the first row's right half) extends the `contextActivity` ledger with per-day pricing records — each day's billed buckets keyed provider → model → pricing period, the same `SessionCostUsage` raw material the timeline's session-cost totals carry, booked at each turn's initiation instant and priced client-side by the same book and estimator as the KPI band. Booking fees per (provider, model, period) changes the fold's state shape, so the unit's `stateVersion` bumps (1 → 2): on upgrade every cached `contextActivity` row reads version-stale, and the page's existing warm-up (above) cold-refolds the corpus from the durable logs, rebuilding every day's tokens AND fees exactly the first time the panel opens. On the wire the field is additive-optional: a day folded before it existed (or whose settlements carried no model header or no usage) serves without it, and the chart degrades to tokens-only bars for those days — the same sanitizer contract every other projection value rides. - -The Insights page's skill card (the activity column's second card, under the heatmap: every skill loaded within the range — or the heatmap's pinned day — with its load tally and last-load instant, a row click pinning the session list to that skill's loaders) extends the `contextActivity` day entries with a per-name skill table. The fold reads the same two durable load gestures the timeline fold tags `sub: 'skill'` — a user-explicit `/name` invocation's `skill-invocation` source message and a `skill`-tool result's `` wrapper (the available-skills catalog digest carries no name and counts nothing) — and tallies them onto the load instant's local day, capped at 100 distinct names per day so a hostile replay cannot bloat the row. The tool-side gesture is trusted only under pairing: a result's wrapper counts solely when its `callId` matches a pending `skill`-tool call the fold tracked, so a `read` of source text merely QUOTING the wrapper (this file's own comments, say) never books a load; the pending list is bounded (100 ids) and consumed on pairing. Booking per-day tables changes the fold's state shape, so the unit's `stateVersion` bumps (3 → 4): on upgrade every cached `contextActivity` row reads version-stale and the page warm-up cold-refolds the corpus, rebuilding every day's tallies exactly. On the wire the field is additive-optional: a day folded before it existed (or without a load) serves without it, and the card reads the day as load-less — the same sanitizer contract as the pricing records. - -The card's enrichment (what a loaded skill IS: its registry description, origin bucket, and on-disk path, joined by name onto the tallies — shown as a structured hover card per row and, while a skill is pinned, as the detail block under the rows whose path line copies on click) rides the plugin's own `/api/dsh-context/skills` fetch route, served off the host's skill registry rather than the harness's per-session `skills/list` Remote, whose wire shape strips the origin. The route mirrors that Remote's session-addressed resolution with re-proved faces — the session's observation yields its cwd and recorded preset, a live agent's preset-scoped registry wins, a cold session's preset rides a standing scope lease — because web compositions mount skill discovery on agent presets and the unscoped global registry lists nothing. Every rung degrades: the faces are read per request (an absent or hostile registry/preset/session service answers a typed `null` or the next-coarser view), the client re-proves every entry at its boundary (a failed proof drops that entry, a failed read renders the tallies unenriched), and a deployment without the connection fetch-route registry simply never arms it — no version gate, no state, nothing to backfill. - -The timing card's session-time strip (the card's bottom band: every completed step's TTFT wait, decode blocks in stream order, tool-run windows, and in-step residue packed gapless in occurrence order — each slice's width is its share of the session's cumulative active time, with ticks at zero, the quartiles, and the full span) bumps the timeline projection's `stateVersion` (22 → 23): the strip's painted spans are a per-step collection the fold flushes at `step/end`, which later events cannot backfill for steps already folded (the timing totals' own v11 precedent), so on upgrade every cached timeline row is invalidated and refolded from the durable log, which rebuilds the spans for sessions started under older plugin builds; the v20 warm-up rebuilds idle sessions' rows the first time the page opens. On the wire the collection is additive-optional — it rides the detail channel (and the inline value on channel-less hosts), never the slim head — so a payload folded before it existed serves without it, and the timing card shows no strip then. The spans are bounded to the newest 2,000 records (a plain count cap), so an outsize session's strip paints its recent window; untrusted spans are re-proved per item at the client's render boundary, the same sanitizer contract every collection rides. - -The timing totals' wait/generation boundary moving from the first token to the first observable instant (above — an earlier `block-start` marker wins when the token packing could not stamp it, the same boundary the strip's spans already paint) bumps the timeline projection's `stateVersion` (23 → 24): rows folded under 23 charged the marker-tiled decode window to BOTH the wait and the generation split, so their legend rows sum past 100%, and the mis-split totals cannot be repaired by later events, so on upgrade every cached timeline row is invalidated and refolded from the durable log; the v20 warm-up rebuilds idle sessions' rows the first time the page opens. The wire shape is unchanged — the same `ttftMs`/`genMs` fields carry the corrected sums — and the TPS chip keeps the harness-parity first-token anchor, so only the card's wait/generation shares move. - -Accounting for `developer/message` tool-registry changes bumps the timeline projection's `stateVersion` (24 → 25): earlier rows omitted these model-visible messages from their surface, request composition, and archives. On upgrade the derived rows refold from the durable log; the existing page warm-up also rebuilds idle sessions. The wire shape stays unchanged. - -The trend chart's duration overlay (the card's Duration toggle: each request's step active time drawn as a curve over the bars, read off the right-hand quartile axis) bumps the timeline projection's `stateVersion` (25 → 26): the per-request `activeMs` is stamped at `step/end` fold time — the whole step window (the model call plus its tool runs) minus the step's user waits, those being approval decision intervals (`approval/asked` → `approval/decided`, a crash-dangling pair closing at the step's own end) and `ask_user_question` answer windows (the tool's body is the wait itself, in both the blocking and the timed schema's foreground phase) — so requests already folded can never gain it later (the v9 `cacheRead` precedent), and on upgrade every cached timeline row is invalidated and refolded from the durable log, while the v20 warm-up rebuilds idle sessions' rows the first time the page opens. On the wire the field is additive-optional: a request whose step is still open (the live tail) or that folded without one (an unpaired/hostile log) serves without it, and the overlay's curve simply breaks at that bar instead of faking a point — the same sanitizer contract every collection rides. - -The session node's answers tally (`answers` on the `contextTimeline` head — one per assistant message carrying a non-blank text block; steps that only dispatched tool calls are work, not replies) bumps the timeline projection's `stateVersion` (26 → 27): it is a running total over the complete log that later events cannot backfill for sessions folded before the field existed (the v16 `humanInputs` precedent), so on upgrade every cached timeline row is invalidated and refolded from the durable log, while the v20 warm-up rebuilds idle sessions' rows the first time the page opens. On the wire the field is additive-optional: a row folded before it existed serves without it and the card degrades to a zero figure. The head's sibling `counts.skills` figure (the distinct loaded-skill names) needed no bump of its own — it is derived at view time over the retained events already living in every cached row's state (skill-tagged inject events since v19), so a pre-field row recomputes the exact tally the moment this build serves it. - -The Current Context bar's two auto-compaction reserve masks need the routed request's output cap, which bumps the timeline projection's `stateVersion` (27 → 28): the cap is the newest `request/header` envelope's materialized `config.maxTokens`, folded last-wins exactly as the compaction backend's own `reservedCompletionTokens` reads it, and a header event already folded can never be replayed into a cached row while its session is idle; on upgrade every cached timeline row is invalidated and refolded from the durable log, while the v20 warm-up rebuilds idle sessions' rows the first time the page opens. On the wire the field is additive-optional: a row folded before it existed, or a log whose header carries no cap, serves without it and the bar paints the headroom mask without the output one (the client re-derives the reserve per serve — `src/client/reserve.ts`), the same sanitizer contract every other scalar rides. - -The Fleet tab's per-agent detail (delegation prompt, descriptor composition, latest reply, inter-agent messages, and the lead's roster facts) rides the plugin's `/api/dsh-context/fleet` fetch route — the same authenticated `/api` fence, Connection exact-route registry, and cold-read gate as the detail route, folding nothing token-shaped of its own: it lifts `subagent/descriptor`, `team/member`, and `user/message` relay/mailbox/settled records out of one `sessionQuery.observeSession` read per session (live-preferred, so live and cold sessions share the path). Every seam degrades independently: a deployment without the route (or a skipped cold read) renders the inspector's core and hides the detail sections; a harness without the experimental Agent Teams plugin serves no `agentTeam` projection, so the members board and task board simply never render; the relay/mailbox/settled source kinds absent from a session's log yield an empty comms timeline, not an error. Sender-framing preambles ("Agent sent a message:", "Team message from :", "Team message from :") strip at extraction; the `subagent-settled` English boilerplate maps to localized variants at render, with unrecognized phrasings falling back to the raw text. - -To force a full refold of an existing session sooner, delete its cached projection row — it is a derived cache and is rebuilt from the durable log: - -```bash -rm ~/.dsh/storages/session_projcache/sessions/.json -``` +- **Check — seam matrix** (the `compat` vitest project, every `pnpm test` in CI): stages the harness's REAL sources per baseline tag, boots the BUILT plugin into the tag's actual registries on its vendored cordis, probes the host and client seams, asserts retired seams ABSENT (`settings.register`, settings `get(ns)`, sessions `open(id)`), and asserts every event family `src/host/fold.ts` switches on is declared by every baseline. Requires a dsh checkout (`DSH_REPO`, default `~/dev/deepseek-harness`) and `pnpm run build` first; CI fails on a missing tag, a dev box skips with a printed reason. +- **Check — differential statistics:** figures verified equal to the harness's own projections (`sessionStats`, `contextBreakdown`, `contextPressure`, `tokenUsage`) over real session logs — tokens, request counts, turns/steps, TTFT, generation, tool time, billed cost buckets. +- **Check — install (manual, per release):** install the exact CLI into a disposable `DSH_HOME` (never the real `~/.dsh`); `dsh plugin add dsh-context` → `--dump-config` shows exactly 1 composed row → `dsh plugin remove` → 0 rows. Pin an exact plugin version for a just-published build — the profile's package-manager release-age policy can lag. +- **Check — hostile fixtures:** every guard branch carries them (wrong types, missing fields, throwing accessors) — an untested guard is an unverified promise. +- **Procedure — add a baseline:** one `Baseline` entry in `tests/baselines.ts` (tag, vendored cordis, faces) plus the tag in both workflows' fetch step; run `pnpm test` and re-fit whichever seam a failing probe names. +- **Limitation — scope:** these checks prove source-level seam compatibility, statistical parity, and install/uninstall per release — not full web-app runtime acceptance on a real profile.