Files
dsh-context/docs/compatibility.md
T
bowenliang123 96c8b92caa feat(insights): promote the dashboard to a first-level sidebar panel page
The Context Insights surface moves from a sidebar-foot button opening a
frame-wide overlay to a first-level panel, a sibling of the shipped
Plugins and Automation tasks pages: the page registers on the layout's
keyed `main` slot and the entry on `sidebar.panellist` under one id
(`dsh-context`), so the shell owns the entry row and panel switching —
mounting the page IS opening it (no plugin-side open state, the footer
entry, the overlay seat, and the overviewStore all go away). The
`insightsEntry` preference now mounts/unwinds the registration pair.
Both seats ship on every supported line (0.1.5-rc.1+), pinned per
baseline by the new insightPage seam probe; runtime-verified on a
disposable 0.1.5-rc.1 profile.

The chrome mirrors the shipped first-level pages: one scroll region
with the 960px centered column, the 20px page heading beside the range
group and the balance capsule, and the page element as the `lc-ov`
query container. Inline-size containment makes the page the containing
block for fixed descendants, so the heatmap/usage/balance tooltips can
no longer ride the harness Tooltip (its portal prop is 0.1.7+): they
now carry `data-lc-tip` served by the page's own hover tip — one fixed
bubble portaled to <body>, delegated off the page root (the image
lightbox's precedent), correct on every supported line.

Session card jumps need no close (openSession's own navigation returns
to the conversation); the settings row jump unmounts the page by
selecting the Plugins panel. Verified across 1440-390px in both
locales, and the /context modal is untouched.
2026-10-05 11:20:59 +08:00

30 KiB

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.

Last verified: 2026-09-29 (plugin dsh-context@0.60.0 source tree, dsh 0.2.0-rc.2).

Supported dsh releases

dsh release Session log Declared Automated seam matrix Disposable-profile install / uninstall
0.1.5-rc.1 V3 compatible ✅ baseline v0.1.5-rc.1 ✅ install OK → 1 composed row → uninstall OK → 0 rows (verified 2026-09-10)
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)

The automated seam matrix runs for every row on every pnpm test. 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.5-rc.1 on 2026-09-10 (a stale mirror can 404 the harness's own dependency closure before the plugin is even considered), then 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.

The supported range is the three lines above: the 0.1.5 line from rc.1, 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.2/0.1.3 generations, the 0.1.1 line, and the alpha previews — was supported through dsh-context@0.55.x and 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.

Harness-side compatibility admission (0.1.7+)

From 0.1.7 the harness 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.5-rc.1, which every supported release — all three prerelease lines included — satisfies, so the plugin composes normally with no exemption. The 0.2.0 line keeps the same gate.

Baseline gate

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.5-rc.1 (channel order: release > rc > beta > alpha):

  • Below the floor — fallback projection units that fold nothing and serve zeroed data: the Context tab keeps its (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.

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.

Session-log generations

The supported range spans three baselines and two durable-log generations, and the plugin folds both generations from one shape-driven code path: the spellings meet only in src/host/logShapes.ts (firstTokenTimeOfStream, replaceRangeOf, isTokenChunk), and applyTimeline runs a single code path over all of them:

Seam V3 (0.1.5-rc.1+) V4 (0.1.6/0.1.7+, 0.2.0+)
System prompt system/message surface node (a request/header carrying header.system is rejected outright) same as V3
First token embedded assistant/message.data.stream (also assistant/attempt.data.stream) — packed delta runs plus raw chunk records same as V3
Replacement endpoints { startSeq, endSeq } same as V3
Nested PTC dispatch tool/ptc-dispatch same as V3
Tool result tool-result wrapper block, role: 'user' role: 'tool', lifted message.toolCallId/message.isError, direct content (no wrapper block)

Legacy logs never reach the fold. The running harness migrates every historical log to its own generation 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. 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 V3/V4-shaped by construction, and the pre-V3 spellings (header.system envelopes, assistant/chunk floods, { start, end } endpoints, tool/code-dispatch) exist nowhere in the code.

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.

Known V4 deltas

  • Developer messages: V4 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.
  • Tool-result error mark: V4 made the event-level data.error identity optional while lifting isError onto the message, so the fold reads the flag from BOTH spellings (src/host/fold.ts); a V4 result carrying only message.isError still flags its surface node and file-op row.
  • 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 (shipped): the V4 line (first shipped in 0.1.6-alpha.2) retired the settings.register host face and the settings.plugin.item browser slot — plugin configuration moved to the Plugins page, derived from each loader entry's own Config schema. The plugin serves both generations from one entry schema:
    • The entry Config (src/host/config.ts) is schemastery. Cordis still validates the config: block through its Standard Schema face on every supported line; on V4+ the SAME schema is what the settings describe projects — the namespace is the entry id (dsh-context) and the eight 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 seat the running line declares, by service presence: settingsScope + settings.plugin.item on V3, configForms + the Plugins page's keyed plugins.bundle.config on V4+ (ctx.configForms.whileServed keeps the card alive only while the Host serves the namespace). Both seats render the same eight preference rows.
    • The .volatile() modifier exists only on the Config-form generations' schemastery; the schema builder feature-detects it, so the same bundle serves both generations.
    • Deliberate relaxations against the replaced zod schema: schemastery objects merge unknown keys through instead of failing the load, and only whole-value edits are rejected by range/step checks (bounds remain min 1, step 1).
    • Preference values persisted on the V3 line live in the settings document (settings.yaml section dsh-context); the V4 harness's own legacy import moves that section into the profile entry of the same name on first boot, so existing values carry into the new surface.

Parsing resilience [IMPORTANT!]

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".

  • 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 or /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.
  • 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.

Low-level parity

The plugin's low-level logic mirrors the newest supported dsh implementation: token estimation re-fits dsh-token-meter's estimateSystemMessage (estimateSystemTokens / estimateSystemContent), and the TPS chip's first-token rule matches dsh-llm's assistantStreamFirstTokenTime. 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").

Web client seams

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 an older harness 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:

Seam V3 (0.1.5-rc.1) 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 seat hangs under it) same as V3
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) same as V3
Right Sidebar tab + guide entry sidebarRightTabs registry, sidebar.right.pane.tab(.title) seats, guide capsule with order/title/description/icon same as V3 (0.1.7-rc.2+ adds an optional commandId to guide entries and bindCommands to tab actions — additive, unused by the plugin)
Preferences card seat settings.plugin.item over the settingsScope transport plugins.bundle.config over the configForms transport
Session jump uiWorkspace.openSession — the sidebar row click's own verb; the retired sessions.open(id) spelling is still served as a fallback uiWorkspace.openSession(target: SessionTarget) — same verb, parameter re-spelled by the 0.1.6 selection refactor; the sessions service carries no selection verb any more

The guide capsule carries order, title, description, and icon; 0.1.5-rc.1+ renders the description line (the guideEntry probe in tests/baselines.ts pins the fields of both supported generations).

The File Activity card's file-name affordance is an optional-column story: on a line with the right Sidebar it 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.

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, with the sessions service's retired open(id) spelling kept as the degradation path for a composition without the workspace module. Both faces are re-proved per call and a deployment serving neither (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 called sessions.open alone, 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 now follows the view owner first and the per-baseline sessionNav probe pins both spellings presence-as-declared.

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 through whichever face the running line serves: the get(ns) registered-section read through V3 (the llm-deepseek namespace), the Config-form describe() projection on V4+ — there 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 on either face), 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.

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; the release workflow fetches the pinned baseline tags before testing. Preferences seats and transports are asserted BOTH ways: each slot exists on exactly one supported generation, so the plugin's deferred registrations pick their side and never pend. To add a future dsh release: add one Baseline entry (tag, vendored cordis, faces) to tests/baselines.ts and its tag to the release workflow's 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 <disposable> add dsh-context — install OK;
    2. dsh --profile <disposable> --dump-config — the bundle's - id: dsh-context row composes into the effective configuration (exactly 1 row);
    3. dsh plugin --profile <disposable> 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 <available_skills> 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 ring renders the full composition the first time the card opens. 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), the card degrades to the pressure-only occupancy ring as before.

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).

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 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.

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:

rm ~/.dsh/storages/session_projcache/sessions/<session-id>.json