# Changelog All notable user-facing changes to dsh-session-surgeon. Dates are UTC. ## Unreleased > The rolling `#main` install reports **0.2.11** from 2026-10-25; this section is what that install carries. ### Added - **`verify`: ask the installed reader instead of trusting our own model.** Every other command models the loader's gates; a model is a hypothesis, and this one has been wrong before. `dsh-session-surgeon verify [root]` hands each session file to the same entry point the app uses (`readStoredLog` from `@deepseek-ai/dsh-session-persistence-jsonl`) and prints its verdict next to ours — `accept`, or `refuse` with the reader's own message — so the answer to "which of my sessions is the broken one" comes from the loader rather than from us. That question is what makes one bad file poison a whole feature: session search cold-reads every persisted session and aborts on the first failure, so 8 unreadable sessions out of 170 turned every `session_search` call into `session history storage is unavailable` while exact reads still worked ([#10815](https://github.com/deepseek-ai/deepseek-harness/discussions/10815)). The report names the reader it used and its version, because that decides the verdict: on this machine the 0.1.7-rc.2 reader resolved from the checkout **refuses three sessions written by 0.2.1** for `job/registered`, which a 0.2.1 reader accepts — `DSH_SURGEON_READER=` points it at a different copy. Reading is the only thing it does: a test asserts the file's bytes and mtime are unchanged afterwards, and with no reader installed the command says so and falls back to our health instead of guessing. ### Fixed - **The plugin loads again on DSH 0.2.x — the peer range no longer caps the major.** `peerDependencies` said `@deepseek-ai/dsh-tools >=0.1.0-rc.6 <0.2.0`, so the desktop manager read the 0.2.1-rc.2 tools package as incompatible and refused to load us: *"dsh-session-surgeon@0.2.6 与 DSH 0.2.1-rc.2 不兼容(要求 @deepseek-ai/dsh-tools >=0.1.0-rc.6 <0.2.0)"*. The cap was a guess, not a measurement — the only thing we import from that package is `defineTool`, and it is still exported in 0.2.1-rc.2 (checked against the published tarball, alongside a new `./presentation` entry point we do not use). The import is already optional: when the module is missing we log and skip tool registration instead of crashing, so an upper bound buys nothing and costs every future harness. The range is now `>=0.1.0-rc.6`, and a test asserts it carries **no** upper bound, because this is the second time a too-narrow declaration took the plugin down without a single line of runtime code being wrong. - **A `turn/end` that carries no usable reason is reported instead of `ok`** (new health code `turn-end-reason-invalid`, [#10489](https://github.com/deepseek-ai/deepseek-harness/discussions/10489)). This one is not a loader defect — it is the opposite, which is why we missed it: the relationships fold compares only the turn coordinates and `Session.fromRestore` never reads `reason`, so a row written as `{"type":"turn/end","data":{"turn":14,"reason":null}}` **loads fine** and every detector we ship stayed quiet. The official chat client then reads `turn.end.data.reason.kind` with no null guard and throws, so the session is blank every time it is opened, with no way out from the UI. The report traces it to `agent.cancel()` with a `kind` outside `AgentCancelCause`: the kernel throws `unreachable variant` and still persists the row, and one third-party plugin breaker was enough. Measured on this machine first — 27 sessions, 116 `turn/end` rows, **all** object reasons, so the predicate has no false positives here — and both released gates accept the row we report, which is now pinned by a test so the code cannot quietly become redundant. Repair writes nothing: whether the turn completed, errored or was cancelled is not in the artifact, and inventing a reason would turn a guess into a record. - **A log compressed into one frame is decoded and re-framed instead of being written off as a corrupt header** (new health code `header-frame-multiline`, [#10440](https://github.com/deepseek-ai/deepseek-harness/discussions/10440)). The reader's very first assertion is that frame 0 is *exactly* the header line (`corrupt Zstandard session log: first frame is not exactly one header line`); a script that decompresses every frame, appends a row and recompresses the whole file as one frame therefore produces a log that is refused whole even though **every line in it is intact**. That is 12 of the 13 damaged sessions in the report, and we were refusing them too — we only said "header cannot be decoded". The split is now applied before anything else: the first line becomes the header, the rest is fed to the scanner ahead of the following frames. Measured end to end on a real 326-event v4 session: single-frame → refused by the released persistence with the reported message; after `repair` → **accepted, 326 events, deep-equal to the original**, and the re-encoded file has the required layout because that is all the repair is. Nothing is invented: a first line that does not parse as a header still reports `header-frame-corrupt` and refuses. - **A `session/title` citation list the fold refuses is diagnosed, and repaired when the answer is in the log** (new health code `title-references-invalid`, [#10440](https://github.com/deepseek-ai/deepseek-harness/discussions/10440)). Two clauses, both measured against the released fold rather than read off a document: a **user** title must carry an empty `messageSeqs`, and every other title must cite **distinct, earlier, human** `user/message` rows — `source.kind === "user"`, so an injected `runtime-context` or `team-message` row is not citable. Failing either refuses the whole log (`session/title messageSeqs must be empty exactly for a user title`, `must cite distinct earlier human user/message events`). The report's variant C came from a retitle script that collected "the events nearest the title": 13 citations of which 10 pointed at `step/start`. Repair writes only what is determined — a user title's list is emptied, and citations that name no earlier human message are dropped (keeping the valid ones) — and reports a non-user title left with nothing citable, because which messages it came from is not in the artifact. Verified both ways: two shapes refused by the released fold are accepted after repair, and the two unfixable ones still refuse. Detection is v4-only: earlier stages rewrite title rows, so judging a v3 row would report a violation the loader never sees. - **A v0 refusal no longer fires on the generations whose converter never sees it — and `inspect` stopped flagging healthy v3 sessions.** Found by pointing the scanner at this machine's own store instead of at a report: both v3 sessions in it were reported `v0-plugin-source-form` and `planRepair` returned `mustWrite: true`, so a user running `repair --apply` on an untouched history would have had **healthy files rewritten**. The rule is a **v0→v1** gate, and loading a file runs only the converters from its own generation upward, so it is evidence about generation 0 and about nothing newer. Every `v0-*` code now carries the newest generation whose stage owns it — `v0-preset-extra-member`, `v0-descriptor-version`, `v0-plugin-source-form` and `v0-inbox-inserted-message` stop at v0, `v0-chunk-provenance` at v1 (the v0→v1 and v1→v2 edges both refuse it), `v0-retired-source-kind` at v2. The boundary was measured, not assumed: the v0 gate has no kind whitelist at all and **accepts** `instruction-hint`, so that rule is the v2→v3 table's alone. Both v3 sessions now read `ok` with `mustWrite: false`; the one that also carries an unresolved tool call still reports `dangling-tool-call`, which is a real refusal on a closed step. - **`v0-plugin-source-form` now matches the converter's own rule, shape for shape.** The old predicate keyed off the *presence* of `summary`/`sections` and never looked at `form`, which was wrong in both directions. False positives: `form: "snapshot"` + `sections` (accept), `form: "notice"` + `summary` (accept), and `summary` with **no** `form` at all — the converter `return`s at `form === undefined` and never examines either member. False negatives, where we said `ok` while the loader refused the whole log: `form: "notice"` with no summary (`summary must be a string`), `form: "snapshot"` with no `sections` (`sections must be an array`), and an unknown `form` literal with neither member. The repair now drops only what cannot be rendered (a non-array `sections`, a non-string `summary`) and a `form` that no member justifies, because keeping an unsatisfiable display hint costs the entire session; when both members survive it keeps the **structured** one (`sections` → `form: "snapshot"`) and drops the one-line `summary`. Fourteen shapes are pinned against the released stage itself: our verdict must equal the gate's on every one, and every repaired row must load. Section members are deliberately not validated here — dropping a structure we merely fail to recognise would be worse than not reporting it. - **A repeated PTC sub-call id is no longer reported `ok`** (new health code `ptc-dispatch-invalid`, [#9677](https://github.com/deepseek-ai/deepseek-harness/discussions/9677)). A `run_code` sub-call id is `:ptc:`, and **both halves can repeat inside one session** — the provider re-issues a parent id when the assistant repeats a call, and `n` restarts at 1 for every `execute()`. A sub-call that outlives its step (polling a background job is the natural one) therefore lands a second `tool/ptc-dispatch-start` on a sub-call id that is already settled. Nothing rejects the write; the relationships fold refuses the **whole log** on the next read (`PTC dispatch repeats subCallId`, inside `stored log is corrupt`), and because reading is all-or-nothing one repeat costs the entire session — the report is a real 3569-event session that became permanently unopenable. Six mutations of a real dispatch pair now report the code where we said `ok`: a repeated `subCallId`, a dispatch reusing a settled one, a `name` that differs from its start, an emptied `rootCallId` or `subCallId`, and a `parentCallId` that belongs to nothing. The rules are copied from `Relationships.dispatch()` rather than guessed, and one asymmetry is worth recording: **the seed constructor accepts all of these** — only the fold checks them — so the fold is the gate that matters here, and it is the one a stored log passes on open. Detection is only the first half here — the repeated id is repairable, see the next entry. The walk stops at the first violation, because the fold aborts there. Verified both ways — all six report the code while the fold refuses them, and all 13 files in the store report `ok` with zero PTC hits, including the one session that genuinely carries a dispatch pair. - **A repeated PTC sub-call id is repaired, not just reported.** [#9677](https://github.com/deepseek-ai/deepseek-harness/discussions/9677) is data loss — one repeat costs the whole session — and it is the one shape in that family where the artifact still holds the answer, because only the **key** is wrong. `repairPtcSubCallIds` renames the **later** occurrence (its `tool/ptc-dispatch-start` and the rows that settle it, up to the next start carrying the same id) and never touches the first id: the first id is the one an existing child row may already name as its `parentCallId`, so it has to keep resolving. That is the scheme `@argszero/dsh-ptc-subcall-guard` applies to the seed it hands the session factory, and applying it to the stored file is what makes the log readable for every consumer instead of only the process that ran the guard. The fresh id is the old one plus the lowest free `::r` suffix, where free means unused as a `subCallId` **or** as a `parentCallId` anywhere in the log. Nothing is deleted and no other member changes: measured on a real 4376-event session that carries a dispatch pair, the rename touches **2 of 4376 rows**, after which **both** released gates accept the file and `planRepair` on the result writes nothing. Repair refuses every other row in this family — a `name` that disagrees with its start, an emptied `rootCallId`, a `parentCallId` that belongs to nothing — and it also refuses a rename whose result is still not clean, so a row broken for a second reason cannot be papered over. - **A surface replacement that no longer names a span on the surface is no longer reported `ok`** (new health code `surface-replacement-invalid`). Found by pointing the false-negative sweep at a second family. A real session in this machine's store carries **44** `tool/result` replacements, and all three mutations of one of them — `surfaceOp.startSeq` or `endSeq` moved off the surface, and `sourceEventSeqs` no longer listing the row it shadows — were refused by **both** released gates (the v4 fold: `tool/result replacement range is not on the current surface`, `replacement sourceEventSeqs omit a shadowed surface node`; the seed constructor: `surface replace: start seq not found in surface`, `sourceEventSeqs must not be empty`) while we reported `ok` with no issues. Every surface-eligible row (`system/message`, `user/message`, `developer/message`, `assistant/message`, `tool/result`) now folds into one ordered surface list: `surfaceOp: "append"` pushes it, anything else must name a span that is *currently* on the surface, must carry a `sourceEventSeqs` array listing exactly the rows it shadows, and may not shadow the protected system head. Repair writes nothing — once `startSeq`/`endSeq` stop naming it, the span a replacement meant to shadow is not recoverable from the artifact, and `sourceEventSeqs` is a claim about the surface that the surface itself cannot confirm. The walk **stops at the first violation** on purpose: the fold aborts there, so every later index is undefined and continuing would only manufacture cascade hits. Verified both ways — all four shapes report the code while the fold refuses them, and all 13 files in the store report `ok` with zero surface hits, including the session with 44 genuine replacements, which is the false-positive check that matters here. Two rules came out of building the fixture, both from the seed gate rather than from a document: a surface-shadowing replacement is a `tool/result` (`assistant/message embeds its source stream and cannot carry sourceEventSeqs`), and it **may change only content** — its message id has to match the row it shadows. - **A row whose turn/step pairing is wrong, or a `tool/result` that no longer names the call it answers, is no longer reported `ok`** (new health codes `turn-step-mismatch` and `tool-result-invalid`). Found by a **false-negative sweep** — the same instrument that hid `producer-source-invalid` in the previous release — over a real 4376-event v4 session: eight semantic mutations that the released relationship walker refuses all read back as `ok` with no issues. A wrong `turn` on `turn/start`, `step/start`, `assistant/message` or `turn/end`; a `tool/result` whose `message.toolCallId` was changed or dropped; a `tool/result` whose `surfaceOp` was dropped. Each one refuses the **whole log** at that row (`turn/start does not open the expected turn`, `step/start does not match the open turn and next step`, `assistant/message does not match an open turn and step`, `turn/end does not match the open turn with no open step`, `tool/result has no advertised tool lifecycle`, `tool call id requires a nonempty string`, `tool/result surfaceOp requires an object`) — worse than a crash precisely because nothing warns the user. The rules are copied from the walker rather than guessed: `nextTurn` and `nextStep` both start at 1, `turn/start` must open the expected turn, `step/start` must be the next step of the open turn, every step-scoped row must carry the open pair, `turn/end` must match the open turn with no step open, and a `tool/result` must name a call the log advertised and agree with its own `message.source.callId` — the last of which the shipped skill had recorded as "not yet probed". Repair still writes nothing: renumbering a mid-log turn has no unique answer inside the artifact (`compact` can renumber only because the slice boundary it is handed *defines* the answer) and the call a result answers is not recoverable once its id is wrong. Two boundaries are deliberate. The detector is **v4-only**: older generations are normalised by their own migration stages *before* they are validated — a real v3 session on this machine keeps its call id in `content[0].toolCallId` with `message.role: "user"`, and the v3→v4 stage lifts it into `message.toolCallId`, so judging the raw v3 row reported a violation the loader never sees (the first version of this module did exactly that, and the real-store sweep caught it, not a unit test). And it does not re-report the #7824 shapes: a row continuing a closed turn stays `step-after-turn-end`, a `turn/end` crossing an open step stays `turn-end-while-step-open`. Verified both ways — the eight mutations now report their code while the released fold refuses them, and all 13 sessions in this machine's store report `ok` with zero pairing hits. - **The repository no longer carries literal fake credentials, so a secret scanner no longer trips on the test fixtures** (raised by @maozhuoshushu on [#9163](https://github.com/deepseek-ai/deepseek-harness/discussions/9163)). A plugin safety gate run over the repository reported 15 high-severity hits; 14 are test-side, and the credential-shaped ones were a fake `sk-…` key and three fake PEM blocks committed as literals in `test/export.test.mjs` and `test/transcript.test.mjs`. They are now built at runtime — one `pem(label)` helper replaces three near-identical literals — so neither `sk-…` nor `BEGIN … PRIVATE KEY` appears anywhere in the checkout. What this does **not** change matters more than what it does: the published package was already free of them, because `package.json`'s `files` list excludes `test/`, `fixtures/` and `scripts/`, and pnpm applies that list for `github:` installs — verified by listing the installed copy under the desktop profile, which holds `bin`, `docs`, `plugin`, `skills`, `src` and no `test/`. The gate is scanning the repository, not the package. The remaining hits are `spawnSync` and `vm.runInNewContext` in the test harness, which a suite that tests a CLI and a browser bundle cannot avoid; the gate would need to skip test paths or honour an ignore file. - **`producer-source-invalid` never fired: the detector was wired to a member the scanner does not return.** The gate read `finished.header.version`, but `scanner.finish()` returns only `{events, overflow, issues, unknownTypes, packedRows, packedOverlapKept, logicalLines, leftover}` — no `header`. The condition was therefore always falsy, so the detector added in the previous release never ran on any file: a log whose `user/message` carries no `source` at all was still reported `ok`, while the released fold refuses it with `format v4 message requires a producer-owned source kind`. The gate now uses the same classified `fileVersion` its neighbouring v4 detectors use. Found by a self-check rather than a report — a **false-negative sweep** that mutates a real 184-event session semantically and compares our verdict with the released fold case by case, which is the same instrument that hid `llm/retry` two rounds ago. Why the existing tests missed it: both producer-source tests called `producerSourceHits(events)` directly and so never exercised the decode path; they now also drive `decodeSessionBuffer` and assert the health code arrives there, and reintroducing the old gate makes that test fail. - **`llm/retry` chains that the validator refuses are now diagnosed and renumbered** ([#9163](https://github.com/deepseek-ai/deepseek-harness/discussions/9163)). The released fold sequences every scheduled retry against the most recent retry of the same `(turn, step, provider, policyKey)` chain — `retry` must be that attempt + 1, and one chain keeps one `retryId` — so a writer that loses the chain state (each attempt stamped `retry: 1` with a fresh id) refuses the **whole log**: `llm/retry skips its policy attempt sequence`, surfacing as `stored session "…" is corrupt`. The write is silent and only shows up when the session is next opened. We reported those logs as `ok`: measured on a real 4375-event session with two extra `retry: 1` attempts spliced in, the released fold refused it while our decode said `ok` with no issues — a false negative, which is worse than a crash because nothing tells the user to look. Both correct values come back out of the log itself, so nothing is invented: the attempt number is the row's position inside its chain, and the id is the chain's own first id; `llm/retry-started` is remapped through the same table (it pairs a scheduled attempt by `(retryId, retry)`). It refuses — writing nothing — when the chain identity contains an object, when a `retryId` is not a non-empty string, when a new chain reuses an id an earlier retry already used, when a chain repeats a `(retryId, retry)` pair, or when a started row pairs nothing. A chain that already sequences correctly is left byte-identical (`fixed: 0`), and all 13 sessions in this machine's store still report `ok` with `mustWrite: false`, so the detector does not fire on healthy logs. Verified by driving both released read-time gates: the broken log is refused with the reported message, the repaired log is accepted, and the healthy shape passes untouched. - **A zero-filled tail after the last committed frame is diagnosed and dropped** ([#9372](https://github.com/deepseek-ai/deepseek-harness/discussions/9372)). A crash between `write()` and `fsync()` leaves the file's new length on disk without the frame data, and that region reads back as `0x00` (NTFS `ValidDataLength`, XFS/ext4 delayed allocation). The official scanner meets the first zero byte, calls it `invalid frame magic at byte ` and refuses the whole log — so a session whose every committed frame is intact becomes unopenable, and historical-generation migration fails the same way. We used to report exactly that as `header-frame-corrupt` with `frames 0` and then refuse to repair it, which was wrong twice over: the header frame was fine, and the committed prefix was readable. `scanZstdFrames` now returns `zeroTailAt` when the run from an offset to EOF is *entirely* zero, `inspect` reports `zero-fill-tail` and still decodes every committed event, and `repair` drops the zeros — a test asserts the repaired events are **deep-equal** to the clean fixture's, so not one committed event changes. The rule stays narrow on purpose: non-zero garbage, or zeros with a real frame behind them, still refuses as `invalid frame magic` rather than being guessed at, and the raw (`compression: 'none'`) log path is untouched. Verified on a real 184-event v4 session with 4096 zero bytes appended: before, `header-frame-corrupt` / `frames 0` / repair refused; after, `zero-fill-tail`, all 184 events, and the repaired file passes `assertReleasedV4Relationships`, `Session.fromRestore` and the embedded-stream comparison. The new `zero-fill-tail.session.jsonl.zstd` fixture pins the defect itself — the builder's own official-shaped scanner must refuse it at the committed length. - **`compact` could not produce a loadable file, and crashed before it could try.** Found by pointing the tool at this machine's own store while answering [#8466](https://github.com/deepseek-ai/deepseek-harness/discussions/8466) — the flagged session there is exactly the "history too big" case `compact` exists for. Three independent defects, all measured against the released reader: (1) the CLI printed `formatRepairText(result)`, which reads `plan.actions.length` — a compact plan has no `actions`, so **every** `compact` run died with `Cannot read properties of undefined (reading 'length')` regardless of `--format`; (2) the written header carried `seedLength`, a v0/v1 field that the v4 header gate refuses as `unexpected field seedLength` and our own `isHeaderLine` forbids for `version >= 2`; (3) the slice renumbered `seq` but left `data.turn`/`data.step` at their old values, so the fold refused it with `turn/start does not open the expected turn`. Compact now drops the earlier turns, renumbers seqs from 0 and turns/steps from 1, and shifts every seq-valued reference that stays inside the slice by the cut — `surfaceOp`/`sourceEventSeqs` on the event, `shadowedSeqs`/`shadowedRange`/`headerSeq`/`messageSeqs`/`throughSeq`/`sourceEventSeq`/`protectedHead` inside `data` (that member list is measured over the whole local store, not assumed: `throughSeq` on delivery events and `sourceEventSeq` on `command/done` are only visible there). It refuses — writing nothing — when a reference points at a dropped event, when a turn/step pair cannot be mapped, or when the session has an inherited seed prefix (`isSeeded`, or a v0/v1 `seedLength > 0`), because dropping an inherited prefix has no defined meaning. Verified by compacting every v4 session in this machine's store at `keep-last-turns` 1, 2 and 3: **0 of the produced files fail** `assertReleasedV4Relationships`, `Session.fromRestore` or the embedded-stream comparison (7 accepted, 6 refused, 17 no-op). A test drives the released gates on a compacted slice, which is what caught (3) and the two missing reference members. - **A lone surrogate is located, and the panel can say what it means** ([#8466](https://github.com/deepseek-ai/deepseek-harness/discussions/8466)). `lone-surrogate` was the one common verdict the panel had no copy for — the badge showed the raw code and the hint fell back to the generic default in all three locales, which is the same "the user sees no reason" the report is about. The message said only "isolated UTF-16 surrogate in payload"; it now names the row: `isolated UTF-16 surrogate in seq 25 (tool/result) — JSON.stringify escapes it and the provider rejects every later request body (HTTP 400, #8466); repair replaces it with U+FFFD`, and carries the seqs. Measured on a real 184-event v4 log poisoned the way the report describes (a `tool/result` text carrying U+DC3E): **both official read-time gates accept the file as written**, which is precisely why nothing warns the user, and after `repair` the escapes are gone, the seqs are unchanged and the gates still accept. The four other codes the panel had no label for are covered too (`torn-tail`, `foreign-version`, `header-parse-error`, `retired-fields`), and a test now asserts every code in `HEALTH_RANK` has copy in all three locales, so this cannot reopen silently. - **A lone surrogate in the header is reported rather than left as a silent dead end.** `inspect` flagged the file while `repair` reported nothing to do, with no hint that the two were talking about different places. A header hit (reachable on Windows, where a directory name can carry an unpaired surrogate and the header records the `cwd`) is now named as such and says it is only reported: rewriting `cwd` would move the log out of the directory that names it, so the repair there is a directory rename, not a file edit. ### Docs - **We no longer tell anyone to delete the projection cache — move it aside instead.** Our guidance for the idle prune pass ([#8812](https://github.com/deepseek-ai/deepseek-harness/discussions/8812)) was to delete `/storages/session_projcache*/sessions/.json` so the projection rebuilds. [#9611](https://github.com/deepseek-ai/deepseek-harness/discussions/9611) measured that the cache documents itself as "a fold shortcut, never an authority" yet behaves as one for the sidebar: with `session_projcache` empty or missing the Desktop shows **"No sessions yet"** and no workspaces, with all 689 session logs intact on disk, and copying the records back restores the list. A renamed record can be put back; a deleted one may not — so the CHANGELOG, `docs/REPAIR-SPEC.md` and the shipped skill now say "move it aside", and `REPAIR-SPEC` records why we still refuse to *judge* by that file rather than only why we ignore it. No behaviour changed; this is advice we had published that could hide a user's whole history. - **The shipped skill had the released producer-kind table one name short.** `RELEASED_SAME_NAME_PRODUCERS` holds **25** entries, not the 24 that `skills/dsh-session-surgeon-update/SKILL.md` and `docs/REPAIR-SPEC.md` both claimed — our miscount, which had already reached a public reply on [#6559](https://github.com/deepseek-ai/deepseek-harness/discussions/6559) and was corrected there. The table itself was never wrong: `src/message-shapes.mjs` copies the released block verbatim, and a test drives the released v3→v4 stage and compares the kind it derives for every mapped name and role. Both documents now also record two facts measured while answering [#9099](https://github.com/deepseek-ai/deepseek-harness/discussions/9099): the rewrite table runs **only** inside the v3→v4 migration (`rewriteV3MessageSource` has a single call site, in that stage's `transformRun`), so the read path — `assertReleasedV4Relationships` → `assertV4MessageSources` → `source()` — never rewrites, and the literal `plugin` kind is a hard refusal there while the `plugin:` fallback is admitted; and the per-slot kind rules come from `dsh-session`'s `assertMessageEventShape`, not from `producerKind`'s role branch. No behaviour changed. ### Fixed - **A message source the reader refuses is no longer reported as `ok`** (new health code `producer-source-invalid`). Format v4 admission runs `assertV4MessageSources` over every message slot on every read and refuses the whole log when `source` is not an object, or `kind` is absent, empty, not a string, or not the kind the slot demands. Our scanner only knew the retired `{kind:"plugin"}` literal, so a row with no `kind` at all — the shape reported in [#9099](https://github.com/deepseek-ai/deepseek-harness/discussions/9099) — read as healthy while the loader refused the file: a false negative, which is worse than a crash because nothing tells the user to look. The rules are measured against the reader shipped in the desktop's `0.2.0-rc.2` bundle rather than assumed, and they differ per slot: `user/message` and `developer/message` accept any non-empty kind except the `plugin` literal, `system/message` accepts only `system-prompt`, `assistant/message` only `model`, and `tool/result` only `tool`. Repair still writes nothing here: an absent or wrong kind has no unique successor inside the artifact, which is exactly why the `plugin` literal keeps its own repairable code and is not folded into this one. A test asserts the detector fires **exactly** when the released gates refuse — both `assertReleasedV4Relationships` and the seed constructor `Session.fromRestore` — across ten measured slot/variant cases, and the four sessions in this machine's store still read unchanged. ### Fixed - **The agent tools work on a standard install again** ([#7](https://github.com/xiaoshenming/dsh-session-surgeon/pull/7), reported and started by @Error0422-bit). Three independent defects made `session_scan` / `session_inspect` / `session_repair` fail with `value is not lossless JSON` or a bare crash. The runtime probe only looked for `@deepseek-ai/dsh-session` next to the `dsh` launcher on `PATH`, so on an npm-global install — where the harness nests its runtime under `node_modules/@deepseek-ai/dsh/node_modules/` — resolution failed, the format version fell back to 0, and every v4 session was reported `foreign-version`. Report payloads carried `undefined` members (`error` on every healthy entry, `tornStart`, `overflowLastSeq`, `events`), which the harness's own `snapshotJsonValue` refuses *whole*, and an early decode return omitted the arrays `inspect` iterates, so a header-only verdict ended in `turnStep is not iterable`. Reproduced here on a machine with no `dsh` on `PATH`: `inspect` crashed exactly that way before the fix and reports normally after it. The probe now anchors on the harness package itself, absent members are omitted instead of carried, the early-return payload carries every array its callers read, and one `toLosslessJson` boundary normalizes whatever a tool returns. - **The event catalog now comes from the install the runtime resolved.** The contributor's probe fixed `runtime.mjs`; `known-types.mjs` resolved the same package its own way and kept missing it, so `KNOWN_SESSION_EVENT_TYPES` silently stayed on the 48-type fallback while the runtime itself resolved — `model/selection` was missing from the vocabulary and a test that only runs once the runtime resolves failed. Both now go through one `resolveInstalledPackage()`, and the catalog loads (59 types here, from `lib/types/known-event-types.js`). - **`toLosslessJson` no longer leaves array holes behind.** The boundary claimed a hole becomes `null`, but it used `Array.prototype.map`, which *skips* holes, so a sparse array came back still sparse and the harness refused it — verified against the harness's own `snapshotJsonValue`: `[1, , 3]` was refused before the fix and accepted after. A test pins each shape (hole, `-0`, `Infinity`, `NaN`, `undefined` member, cycle) to that predicate, so the boundary is measured rather than asserted. ### Fixed - **The retired `{kind:"plugin", plugin}` message source is repaired, not just reported** ([#7772](https://github.com/deepseek-ai/deepseek-harness/discussions/7772), [#6559](https://github.com/deepseek-ai/deepseek-harness/discussions/6559)). Format v4 admission demands a producer-owned kind (`format v4 message requires a producer-owned source kind`), so a log that still carries the wrapper is refused whole on read. The earlier note here claimed the successor kind "cannot be invented offline" — measured against the released converter, that is wrong: the v3→v4 stage derives it with `producerKind(plugin, role)`, a *total* function (a rename table, a first-party same-name set, a role-sensitive `@deepseek-ai/dsh-system-prompt` case, and the `plugin:` fallback), and that function plus `rewritePluginSource` are byte-identical in `0.1.7-rc.2` and in the `0.2.0-rc.2` the desktop bundles. The repair applies exactly that function: it replaces `kind`, drops `plugin`, and keeps every other member (so `automationId` / `runId` / `scheduledFor` survive, which is what the writer's own patch produced). A source whose `plugin` is not a string is left alone, because there the released converter throws. Table drift is guarded by a test that drives the released stage itself and compares the kind it emits for every mapped name and role, and by another that shows the released fold refusing the wrapper and accepting the repaired row. - **`step-after-turn-end` no longer fires on a replacement `tool/result`.** Every released fold routes a `tool/result` whose `surfaceOp` is not the literal `"append"` through `requireTurn()` alone and returns before `requireStep`, so the row's own `turn`/`step` are never compared against the open pair: an idle prune pass that lands inside a *later* open turn is legal, and the only refusal left for those rows is "no turn is open at all" (which `prune-tail-outside-turn` owns). Found the same way as the dangling false positive — by pointing the tool at a real store: a live 3311-event session whose middle carries 21 such rows was reported as `step-after-turn-end` while the verifier shipped in the desktop's `0.2.0-rc.2` bundle accepts it. The detector now skips those rows; appends, `assistant/message` replacements and the rest still go through the turn/step comparison exactly as before. - **`v4-literal-plugin-source` now sees `developer/message` and `system/message` too.** The official walker (`mapEventMessages`) hands those slots to the same converter, and our scan only covered `user/message`, `assistant/message`, `tool/result` and the two message arrays — so a wrapper in a developer or system message was invisible, which is also where the role-sensitive `system-prompt` rename lives. ### Fixed - **`dangling-tool-call` no longer fires on an interrupted tail**, which is the ordinary crash shape rather than a defect. The lifecycle fold only demands a `tool/call` pairing when its step closes (`assertNoUnresolvedTools(toolLifecycles, "step/end")`), so a call whose step is still open at the end of the log is exactly what the engine's own `interruptedTurnClosers()` is about to close on resume. Found by pointing the tool at a real store rather than at a fixture: a 2970-event v4 session carrying an unresolved call in its open tail was reported as `dangling-tool-call` while the verifier shipped in the desktop's `0.2.0-rc.2` bundle accepts it. The detector now tracks step boundaries, reports a call only when its step closed without a result (or the id is empty, which the fold refuses on the call itself), and leaves the open tail to the `open-tail` flag. - **An idle tool-result prune pass no longer leaves a session unloadable** ([#8812](https://github.com/deepseek-ai/deepseek-harness/discussions/8812)). The pruner rewrites old results while no turn is open — `compaction/prune` rows followed by replacement `tool/result` rows whose `surfaceOp` is a replace range — and both released readers route every non-append `tool/result` through a turn requirement (`requireTurn()` in `assertReleasedV4Relationships`, `else if (openTurn === null) throw "tool/result replacement is outside an open turn"` in the 0.2.0-rc.2 worker). After `turn/end` there is no open turn, so the writer emits a log its own reader refuses and the whole session stops loading, with no in-product recovery. New health code `prune-tail-outside-turn`; the repair cuts the log back to the `turn/end` the pass followed, and it only does so when **everything** after that boundary is the prune pass itself. That is what makes this one repairable where a turn that simply kept writing past its `turn/end` ([#7824](https://github.com/deepseek-ai/deepseek-harness/discussions/7824)) is not: a prune replacement is pure surface metadata — the fold itself requires `sourceEventSeqs` to name exactly the surface nodes it shadows, all of which are still in the log — so the pre-prune surface comes back with no message, turn or tool result lost, while real work in the same position would be. A tail that carries anything else (a `user/message`, a new turn, a plugin event), a replacement that shadows a node at or after the cut, or a pass with no `turn/end` before it is refused with the reason spelled out, because dropping it would trade one unloadable log for another. Verified end to end against the verifier shipped in the desktop's own `0.2.0-rc.2` bundle (`worker.cjs`, which runs the relationship fold, `Session.fromRestore` and the content/stream cross-check): the pruner's exact output is refused with `tool/result is outside an open turn`, and the repaired artifact verifies. If the transcript still shows the pruned text after a repair, move that session's projection cache record aside (`/storages/session_projcache*/sessions/.json`) and let it rebuild — **move, not delete**: the cache documents itself as "a fold shortcut, never an authority", but in practice an empty or missing `session_projcache` makes the Desktop sidebar show "No sessions yet" with every log still intact ([#9611](https://github.com/deepseek-ai/deepseek-harness/discussions/9611)), so a renamed record can be put back and a deleted one may not. ### Fixed - **The panel's own buttons work on DSH 0.2.0 again**: 「返回会话」 and 「⋯ → 用会话医生查看」 did nothing on the desktop, and the switch was skipped without a word. Cordis 4's context proxy throws `cannot get property "layout" without inject` for a direct read of a service the plugin did not declare, and the layout service is installed with `ctx.reflect.provide`, which puts no accessor on the context. The browser half declared only `sessions` and `locale`, so `ctx.layout` threw, the `ctx.get("layout")` sitting behind it was never reached, and `layoutOf()` returned `undefined` from its own `catch`. Every optional service read now goes through one `serviceOf(ctx, name)` helper that asks `ctx.get()` first — cordis' own optional lookup, which resolves a service whether or not it was declared — and only then falls back to a direct read, so a shell that declares no slot seats still reaches the overlay path instead of parking. Reproduced against the shipped framework rather than argued: with cordis 4.0.4 taken out of the app bundle and a provider registered, `ctx.layout` throws while `ctx.get("layout")` returns the service. `test/ui.test.mjs` now hands the plugin a context that mirrors that proxy (direct read throws, `get()` resolves); the button test fails against the previously shipped `plugin/client.js` and passes here. Reported with a fix by @Renzishi ([#6](https://github.com/xiaoshenming/dsh-session-surgeon/pull/6)). - **The `duplicate-tool-call-id` repair no longer writes a file the loader refuses** ([#5909](https://github.com/deepseek-ai/deepseek-harness/discussions/5909)). Suffixing a later duplicate and remapping only `tool/result.source.callId` left `message.toolCallId` pointing at the old id, and the released lifecycle fold reads *that* member — so a repaired v4 log died on the next load with `format v4 tool/result at seq requires toolCallId matching its tool source`. Rows now carry both members, and the mapping survives a `tool/result` surface replacement by remembering what the replaced row was given instead of counting the replacement as a fresh result (`tool/result surface replacement may change only content`). The embedded `data.stream` is rewritten with it: the verifier reassembles the stream and compares it against `message.content`, so ids that agree in one place and not the other are refused as `content disagrees with its embedded stream`. All three record shapes that carry the id are covered — the compact `tool-call-chunks` run, a `block-end` whose block the assembler returns verbatim, and an unmerged `tool-call-delta` the accumulator keeps when a name is empty — while a record whose id cannot be mapped makes the repair refuse rather than guess. Verified through the official generation verifier (`worker.cjs`, shipped in both 0.1.7-rc.2 and the desktop's 0.2.0-rc.2, which runs the relationship fold, `Session.fromRestore` *and* the stream cross-check): the raw duplicates are refused with `assistant/message repeats advertised tool call call_x`, the repaired artifact verifies, and dropping either the `toolCallId` or the stream rewrite makes that same artifact fail again. The check now runs in `test/duplicates.test.mjs` wherever the verifier is installed. Fix started by @Renzishi ([#6](https://github.com/xiaoshenming/dsh-session-surgeon/pull/6)). ## 0.2.1 > The rolling `#main` install reports **0.2.1** from 2026-09-29; this section is what that install carries. ### Fixed - **A settlement-broken assistant row is now repaired, not just reported** — and this corrects what the entry below claimed in the previous release. `stream: []` was said to be unrepairable; measured against the installed runtime, it is not. `assertCurrentAssistantStreams` (persistence-jsonl `lib/index.js:1833`) expands the stream and then `continue`s **before** comparing `message.content`, `usage` and `message.source.replayState` whenever the expansion is empty, so an empty array is admissible at both gates — while a deleted or `null` stream is refused by the settlement gate *and* makes the expander throw `stream is not iterable`. The streamed blocks behind `stream` are already gone (that is the defect), so nothing recoverable is discarded: the conversation text lives in `message.content` and is never touched, and neither are `usage` or the replay state. `turn`/`step` are a different case and are not a guess at all — the log states which turn and step are open at that seq, which is exactly the value the row belongs to, and the repair writes that pair; when nothing is open at the seq (the row sits outside any turn) the member is left alone and reported as `settlement-unresolved` instead of invented. Verified end to end against the released gate rather than argued: seven broken shapes (stream deleted / `null` / a string, turn deleted / `-0` / a float, and both at once) each fail `Session.fromRestore` before the repair and construct after it, and a repaired real v4 log constructs while its 579 other assistant rows still pass their content cross-check unchanged. `-0` was mutated on a healthy row and the repair restored exactly the value the row originally carried, which is the correctness signal that the derivation recovers the true value rather than merely an admissible one. ### Added - The panel is a **native center-column page** now, not a full-screen DOM takeover: the browser half registers its own row in the shell's sidebar panel list (`sidebar.panellist`) and its page in the layout's keyed `main` seat, both through `ctx.slots.inject`, so the shell owns the row box, the label, the active highlight and the collapsed rail exactly as it does for the shipped Plugins and Schedule pages. The row carries a real 16px stroke glyph (a speech bubble with a medical cross, `data-dsh-panel-entry="session-surgeon"`) instead of a `✚` text node, and sits at order `-10` — ahead of Plugins (0), Schedule (10) and the task board (20), where the injected row used to be. The ⋯ menu's 「用会话医生查看」 switches the shell to the page with `ctx.layout.selectPanel("session-surgeon")` and opens the picked session there; the page header's 「返回会话」 goes back to the conversation with `selectPanel(null)`. Every color, radius and border rides the shell's own tokens (`--dsw-alias-bg-base`, `--dsw-alias-interactive-bg-hover`, `--dsw-alias-border-l3`, `--dsw-radius-md`, `--dsw-alias-state-error-primary`…), so the page follows the active theme, skin and light/dark mode; `plugin/ui.css` carries no hard-coded color any more. Scanning, the root picker, the transcript, repair preview/apply and export are unchanged, and one shared body renders both mounts, so switching panels neither rescans the disk nor drops the open conversation. A shell that declares no slot seats (pre-0.1.7 web hosts) keeps the previous overlay and DOM-injected sidebar button, and `test/ui.test.mjs` asserts the registration, the fallback and the token-only stylesheet. - `descriptor-catalog-fact`: a `subagent/descriptor` row the **v3→v4 child-catalog path** cannot turn into a supported catalog fact ([#7995](https://github.com/deepseek-ai/deepseek-harness/discussions/7995)). `historicalChildCatalogSource` → `childCatalogFact` is the only reader of `mode`, and it refuses a `provider` that is not a string, a `mode` outside `continuable`/`one-shot` when `version` is not 1, and — via `catalogFact` (`:927`) — a **missing `label` whenever the effective mode is `continuable`**, which includes every version 1 row, because version 1 is normalised to `continuable` before validation. The predicate is not read off the source: it is a 12-shape matrix asserted against the released function itself, so the check and the gate are the same statement. `mode` is a real property and `label` is free text, so neither can be invented — report-only. Two honest limits: the check runs on v1–v3 only (a stored v4 log no longer runs that migration), and only for an **unseeded** subagent child, because the catalog counts rows at `seq >= inheritedEventCount` and that cut is runtime state no on-disk header carries (`assertReleasedV4Header`'s allowed set is version/id/createdAt/isSeeded/delegationDepth/cwd/parentSession/origin/agentPreset). One direction *is* pinned by `assertReleasedV2Artifact`: `if (!header.isSeeded && cut !== 0) throw`, so an unseeded child provably counts the whole file. A seeded child is skipped rather than guessed at. - `invalid-settlement-fields`: an `assistant/message` or `assistant/attempt` row whose `turn`/`step` is not a non-negative safe integer (`-0` counts as invalid) or whose `stream` is not an array. The seed/restore gate (`assertAssistantSettlementShape` in `dsh-session`, which the stored-log read path reaches through `Session.fromRestore`) refuses the **whole session** over one such row — `seed assistant/message at index 4002 has invalid settlement fields` — and until now we printed `health: ok` for it, another false negative in the one job this tool has. Reproduced against the installed runtime rather than read: a real v4 log constructs a `Session` untouched, and deleting `stream` — or setting it to `null`, or `turn` to `-0` — makes the same construction throw exactly that message. The writer's append path does not check these three fields, so a row can be written that only fails on the next load, and by then the log is the only witness. `stream` carries the streamed blocks (a non-empty array, 5..26 entries, in every healthy row measured). Checked from v1 up: a released v0 `assistant/message` may not carry `stream` at all (the v0→v1 payload gate rejects it as an unexpected member), so v0 stays with `v0-missing-member`. Measured at 0 hits across the 7 real logs on hand (v3 + v4) and ~1.6ms on a 140k-event log. **Superseded by `### Fixed` above: this was first shipped report-only on the belief that the row could not be made admissible, which the measurement disproved.** - `v0-missing-member`: rows that kept their type but lost a member the **released v0 inventory requires** — `turn/end` without `reason`, `assistant/message` without `turn`/`step`/`message`/`message.content`, `user/message` without `id`/`role`/`content`/`source`, `tool/call` without `arguments`, `tool/result` without a message, `permission/preset` without `preset`, `agent/inbox/spliced` without `target`/`start`/`inserted`, `goal/change` without `kind`/`version`/`operation`, and a row with no `data` at all. The v0→v1 payload gate refuses the **whole session** over one such row (`… lacks required member "reason"`), and until now we printed `health: ok` for it — a false negative in the one job this tool has. The required sets are derived by **ablation against the released validator**: remove a member, keep it only when the gate refuses that removal, so every entry is enforced by the code that will judge the file. The released package also *declares* its inventory (`RELEASED_V0_EVENT_DISPOSITIONS`, 51 types) and the gate enforces it generically; every entry here matches that declaration, but the declaration is not a safe substitute — it requires `chunk` on `assistant/chunk` while the gate accepts a chunk row that is missing `chunk` *and* one carrying an unexpected extra member, i.e. that type is never validated on this path. Coverage therefore stays partial on purpose, per-block members are skipped (they vary by block type), and the check runs only on a v0 artifact, where that gate actually runs. Report-only: the member is not recoverable from the artifact. Measured at 0 hits across 12 logs, including a recorded v0 fixture that stays `ok`, and the new detector costs ~5ms on a 140k-event log. - Inspect reports **steps that continue a turn the log already closed** (#7824), and a `turn/end` that lands while a step is still open. The released relationship walker refuses the whole session for the first shape: the v1→v2 stage re-runs v0-to-v1's `assertReleasedArtifactRelationships` over the artifact it just produced, so a log whose turn closed before its steps finished dies with `assistant/message does not match an open turn and step` (`step/start … does not match the open turn and next step` for the step-opening variant), wrapped by the orchestrator as *refuses the transformed artifact*. The sibling shape is `turn/end crosses an open step` — the writer states the same rule in `dsh-session/lib/invariant.js`. New health codes `step-after-turn-end` and `turn-end-while-step-open`, both **report-only**: merging the split turn and splitting it in two have no unique answer inside the artifact, and splitting would renumber every later seq and every declared range. Reproduced against the released stage (`restoreReleasedV2Artifact`: a healthy control is accepted, a premature `turn/end` followed by `assistant/message` is refused with the reporter's exact string) and measured at zero hits across the six local sessions. Detection is deliberately narrow — it only fires when a step-scoped event carries a `turn` that already ended, so minimal logs that omit `step/start` stay quiet. - Generation check against **0.1.7-rc.1** (format **v4**, npm `next` at the time; `latest` was 0.1.5-rc.3 and moved to 0.1.7-rc.2 on 2026-09-27): a v3/v4 `session.vN.jsonl.zstd` is decoded, inspected and repaired by the same paths as v0 (`health` stays `ok` on healthy files, a seq gap in a v4 log is still dropped, and the header generation/`isSeeded` survive the rewrite). Nothing in the repair contract was version-gated on v0 beyond the `MIGRATES_V0_ON_LOAD` switches that already follow the installed runtime. - 0.1.5-rc.1 converter refusals on released v0 sessions (`v0-preset-extra-member` [#6189](https://github.com/deepseek-ai/deepseek-harness/discussions/6189), `v0-descriptor-version` / plugin source kinds [#6151](https://github.com/deepseek-ai/deepseek-harness/discussions/6151), `v0-plugin-source-form` [#6194](https://github.com/deepseek-ai/deepseek-harness/discussions/6194), `v0-chunk-provenance` [#6175](https://github.com/deepseek-ai/deepseek-harness/discussions/6175)). The frozen released-v0 inventory refuses shapes earlier writers emitted, so sessions become unopenable after upgrading. Repair (only when the installed runtime migrates v0, i.e. format ≥ 1) keeps just `preset` on `permission/preset`, sets `subagent/descriptor` version 3, aligns plugin-source `form` with `summary`/`sections`, and cites the on-disk chunk run for `assistant/message`. Verified against the real 0.1.5-rc.1 `sessionFormatV0ToV1` stage: refused before, clean migration after. Unknown event types (`session/imported`, plugin messages) are still inspect-only — the converter refuses those even when `ignorable`, and repair does not delete events. - Two more released-writer refusals from the [#6559](https://github.com/deepseek-ai/deepseek-harness/discussions/6559) inventory, still present on 0.1.7-rc.1: `v0-retired-source-kind` — a message `source.kind` literal the writer retired (`instruction-hint`) is no longer in the v2→v3 `SOURCE_KINDS`, so `assertSource` refuses the session as unclassified; the member set is identical to the current `plugin` source, so repair renames the kind only. `v0-inbox-inserted-message` — `agent/inbox/spliced` inserted messages that lack `id`/`role`, which the v0→v1 `messageValue` inventory refuses; repair fills the missing id and the `"user"` role the validator itself passes in. Sources carrying members beyond the released plugin set are left untouched (report only). Verified against the real 0.1.7-rc.1 `sessionFormatV0ToV1` and `sessionFormatV2ToV3` stages: refused before, accepted after. The same gates are present in the published **0.1.5-rc.3** tarballs (`npm latest`): descriptor-version / inserted-message / unresolved-tool assertions all still there, and `instruction-hint` is still absent from that build's `SOURCE_KINDS`. - Follow DeepSeek Harness **0.1.2-rc.1** (native `decodeSeqRanges`, live event catalog) and **0.1.3-alpha** format generations: `session.vN.jsonl.zstd` (`SESSION_FORMAT_VERSION` 2). Historical v0 files stay readable; only a version *newer* than the installed runtime is `foreign-version`. - Duplicate advertised tool-call ids in one step (`duplicate-tool-call-id`, [#5909](https://github.com/deepseek-ai/deepseek-harness/discussions/5909)). 0.1.3 v0→v1 migration throws `assistant/message repeats advertised tool call`. Repair suffixes later duplicates (`id#2`) and remaps matching `tool/call` / `tool/result` in order — never invents an empty id. On 0.1.2-rc.1 (v0) this is inspect-only because the file still loads. - Flat pi-ai `replayState` (`legacy-replay-state`, [#5694](https://github.com/deepseek-ai/deepseek-harness/discussions/5694) / [#5909](https://github.com/deepseek-ai/deepseek-harness/discussions/5909)). Pre-envelope writers stored `{kind, version, api, ..., blocks}` at the root; v0→v1 only admits `{response, blocks?}`. Repair moves existing keys under `response`. On v0 this is inspect-only. - Agent skill `dsh-session-surgeon-update`: saying **更新插件** (or 自我迭代 / 逛逛社区) runs the community-scan → code → changelog → reply → px-push loop without restating the steps. - Web UI follows the app locale (zh / en / nl) and re-renders live ([#2](https://github.com/xiaoshenming/dsh-session-surgeon/pull/2) by @vkpeter). Chinese remains the default when the locale service is missing. - Inspect flags empty `tool_calls[].id` / empty `tool/call.callId` as `empty-tool-call-id` ([#5182](https://github.com/deepseek-ai/deepseek-harness/discussions/5182)). Repair still **does not invent** an id — the engine must filter on replay. - Packed-row overlap suffix (`packed-overlap-suffix`, [#5151](https://github.com/deepseek-ai/deepseek-harness/discussions/5151)): when a packed chunk row starts before the committed cursor but continues through it **and the overlapping prefix is identical to already-committed events**, drop that prefix and keep the uncommitted suffix. Seq numbers already exist on disk — nothing is invented. A mismatched prefix is still a seq gap. - Crash-recovery vs live writer (`live-writer-tail`, [#1586](https://github.com/deepseek-ai/deepseek-harness/discussions/1586) / [#1497](https://github.com/deepseek-ai/deepseek-harness/discussions/1497)): drop official `interrupted-tool-result-*` / `turn/end interrupted` closers when overflow resumes at the same seq with real work. - Detect Alpha compressed `sourceEventSeqs` ranges (`newer-format-ranges`, [#5160](https://github.com/deepseek-ai/deepseek-harness/discussions/5160) / [#4910](https://github.com/deepseek-ai/deepseek-harness/discussions/4910)). **0.1.2-rc.1+** persistence expands pairs on read — surgeon leaves those files alone. Older rc.2 still cannot: repair expands inclusive `[start,end]` pairs losslessly. - Repair validated Alpha `model/selection` events after a downgrade to rc.2 by adding only `ignorable: true`. The official event is log-only and never enters derived model history; type, data, seq, and time stay intact. Malformed variants and arbitrary plugin events remain untouched. ### Fixed - `inspect` and `repair` no longer crash on a **truncated row**. A log whose `assistant/message` kept its type but lost `message`/`content` aborted the whole run with `TypeError: Cannot read properties of undefined (reading 'content')` — the tool dying on exactly the corrupt file it exists to diagnose, from both the CLI and the agent tool. The interrupted-turn walker now reads every member defensively, and such a row is reported like any other defect. Pinned by a corrupt-row test that pushes every truncated shape through `decode` and `planRepair`, plus the CLI end to end. - The message-id fixture was itself a log the official loader refuses (`assistant/message` without `turn`/`step`), so its round-trip test was asserting `ok` for an artifact that would not load. The fixture now carries the members a released row has; the gate accepts it after the repair and the assertion means what it says. ### Changed - **DSH Desktop is the environment this repo is developed and verified against now**, and the install/update story moved with it. The plugin is added from the Desktop **Plugins** page as `github:xiaoshenming/dsh-session-surgeon#main` into `~/.dsh/profiles/desktop`, and **updating** is that page's update action for this plugin — the official in-process manager runs pnpm, which re-resolves `#main` to the new commit (equivalently `cd ~/.dsh/profiles/desktop && pnpm update dsh-session-surgeon`). No `dsh web` restart and no re-`add`: the installed copy is a pnpm checkout without a `.git` of its own, so it is updated, never `git pull`ed. README, README.zh, `cordis.patch.yml`, the settings copy and the update skill (`skills/dsh-session-surgeon-update`, manifest 1.2.0) all state that path; `docs/PLAN.md` §9's knowledge index now lists `IMPLEMENTATION-CONTRACT.md` and the skill itself. The compatibility line in the header still names the released session-format line (npm `latest` 0.1.7-rc.2); the Desktop app bundles `@deepseek-ai/dsh-web-frontend@0.2.0-rc.2`, which keeps `SESSION_FORMAT_VERSION` at 4 and the same v4 gates, and its shell is where the two slot seats above were verified. - Root-caused the settlement-field corruption ([#8084](https://github.com/deepseek-ai/deepseek-harness/discussions/8084)) by elimination rather than by reading: the official writer **cannot** produce a stream-less assistant row. All five `session.append("assistant/message" | "assistant/attempt", …)` sites in `dsh-agent-loop` pass `stream: live.stream`, and `get stream()` is `return [...this.accumulator.snapshot()]` — always an array, never `undefined` — in `0.1.5-rc.3`, `0.1.7-rc.1` and `0.1.7-rc.2` alike. A v0/v1/v2 log cannot produce the *reported* message either: v0→v1 emits `assistant/message` as `turn,step,message` with no `stream`, but v2→v3 refuses that row first (`contentArray(data["stream"], …)` → `… .stream: content must be an array`), so the settlement wording implies the file is **v3 or v4**. The row therefore came from something outside the official agent loop — a plugin or provider adapter calling `session.append` directly — and is not specific to subagents or tool calls. Our own repair is not a candidate: verified across 5 real v4 logs that all 925 `assistant/message` rows keep an array `stream` through a full decode → plan → encode round-trip (the id-fill path spreads the row and replaces only `message`). - A **new minor line** is on npm: `next` moved to **`0.2.0-rc.1`** (2026-09-29; `latest` is still `0.1.7-rc.2`, `alpha` is `0.1.7-alpha.2`), and `@deepseek-ai/dsh-session`, `-format` and `-persistence-jsonl` were published at it. `SESSION_FORMAT_VERSION` is **4 in both** `0.1.7-rc.2` and `0.2.0-rc.1` (`lib/index.js:56`), so the generation did not move and no migration branch is needed; the 0.2.0 discussions on hand all sit behind the same v4 gates. - npm promoted **`0.1.7-rc.2`** to `latest` on 2026-09-27 (it had been `next`), so the compatibility line above records both tags now. The promotion does **not** close the v0 gates: re-read on the installed rc.2 copy, all three sites a released writer trips are still there, only shifted by a few lines — `dsh-session-format-v2-to-v3` `lib/index.js:125` still throws `cannot safely transform unclassified message source` (`SOURCE_KINDS` still 15 entries, `instruction-hint` still absent), `dsh-session-format-v0-to-v1` `:1584` still refuses `data["version"] !== 3` (`:1290` still pins the literal `3`), and `:278`/`:283` still pass the literal `"user"` into `messageValue` for `agent/inbox/spliced` ([#6559](https://github.com/deepseek-ai/deepseek-harness/discussions/6559)). Upgrading to the newest published line therefore leaves those stores exactly as unopenable as before, which is what the three normalizations are for. - Documented a blind spot instead of guessing at it: a log that lost **whole trailing frames** reads back as a *complete* session — every surviving frame validates, the seqs stay contiguous from 0, and the official reader raises nothing (`readZstdPrefix` is zero-tolerance only for a record torn *inside* a frame). Our `torn-tail` code shares that domain, so it cannot see a frame that is simply gone. The projection-cache watermark is the only on-disk witness and we deliberately do not judge on it: only the **current generation** log is comparable (a session directory can hold `session.v3` beside `session.v4`, and comparing the wrong one is a false violation), a lagging cache is legal, and any legitimate shortening of the log — our own committed-gap truncation — would make it fire on a file that is now good ([#8010](https://github.com/deepseek-ai/deepseek-harness/discussions/8010)). - The `dangling-tool-call` message now names both wrappers, so the entry point can be told from the text alone: reading a stored v4 log surfaces the inner assertion inside `SessionPersistenceCorruptionError` (`stored log is corrupt: … (raw log: )`), while the v3→v4 restore/publish path surfaces a bare `SessionFormatError` ([#4549](https://github.com/deepseek-ai/deepseek-harness/discussions/4549)). - Inspect no longer walks the event list a second time for turn/step balance: `decode` returns the hits it already computed, so the CLI report and the issue list come from one source of truth. - Development runtime pinned to **`@deepseek-ai/dsh@0.1.7-rc.2`** (`npm next`, and `latest` since 2026-09-27; format v4) — the pin, the lockfile and the workspace `minimumReleaseAgeExclude` list all move together. Everything in this release was re-run on rc.2: the five-row probe prints the same table, the full suite is green (158 pass locally, CI-equivalent 157 pass / 6 skip), and the known gates (descriptor version, plugin-source `form`, retired `instruction-hint`, inserted-message `id`/`role`) are all still present. - Follow the **installed** DSH runtime (`0.1.2-rc.1` catalog, native `decodeSeqRanges`). Bundled fallback stays the older rc.2 vocabulary. `model/selection` is only shimmed to `ignorable` when the active loader does not know it. Compressed `sourceEventSeqs` ranges are not rewritten on a harness that already expands them. - Peer `@deepseek-ai/dsh-tools` range is `>=0.1.0-rc.6 <0.2.0` so `0.1.2-rc.1` resolves (a caret on a prerelease does not). - Compact refuses unloadable files (seq gap / failed frames / newer format). Repair first, with all writers stopped — compact itself does not create seq holes, but a second live writer after rewrite will. - Inspect keeps overflow after the first seq defect instead of pretending later rows do not exist. ### Fixed - `v0-chunk-provenance` also covers the **v1→v2** edge, not just v0→v1 ([#7824](https://github.com/deepseek-ai/deepseek-harness/discussions/7824)): a message whose declared `sourceEventSeqs` range sweeps in the seq of a non-chunk event sitting just before the attempt (there, a `session/end-seed`) declares one reference too many, and the v1→v2 stage refuses the whole session with `chunk references are not one complete ordered attempt` — on a v0 artifact that surfaces as `历史加载失败` / *failed to observe session*. The repair was already the right one (cite the on-disk chunk run, nothing invented); the copy now names both edges, and a regression test drives the real `sessionFormatV0ToV1` → `sessionFormatV1ToV2` chain: refused before, accepted after. - `v0-chunk-provenance` missed attempts split by `llm/retry` / `llm/retry-started` ([#3](https://github.com/xiaoshenming/dsh-session-surgeon/issues/3)): the chunk-attempt group now resets exactly where the official v1→v2 `closesAttempt` does (`turn/end` / `step/end` / `llm/retry` / `llm/retry-started`), so a message citing chunks from both sides of a retry is flagged and re-cited to the surviving attempt. Replacement messages (`/rewind` markers, `surfaceOp` replace) are never rewritten — their `sourceEventSeqs` are the shadowed surface nodes `applySurface` requires. - `v0-plugin-source-form` repair now aligns the display-only `form` (`notice` for `summary`, `snapshot` for `sections`) instead of deleting the member; only a genuine summary+sections conflict drops one of them ([#6194](https://github.com/deepseek-ai/deepseek-harness/discussions/6194), reporter-confirmed `summary requires notice form`). - The `model/selection` downgrade shim is gated on the **installed** runtime's catalog: a host that knows the type never gets `ignorable` stamped, even when the plugin falls back to its bundled vocabulary ([#3](https://github.com/xiaoshenming/dsh-session-surgeon/issues/3)). Catalog resolution additionally probes beside the running executable, so DSH Desktop installs resolve it without a PATH `dsh`. - Unknown-type classification accepts either the installed catalog or the legacy v0 vocabulary, so 0.1.5 hosts no longer flag retired-but-legal v0 types such as `assistant/chunk`. - Sidebar collapse: the entry button's label now hides via `body[data-dsh-sidebar-collapsed]`, matching what dsh-better-sidebar actually sets ([#4](https://github.com/xiaoshenming/dsh-session-surgeon/issues/4) / [#5](https://github.com/xiaoshenming/dsh-session-surgeon/issues/5)). - Windows `--apply` no longer aborts with `EPERM` when fsyncing the read-only `.bak.` handle ([#4178](https://github.com/deepseek-ai/deepseek-harness/discussions/4178) / [#1452](https://github.com/deepseek-ai/deepseek-harness/discussions/1452)). - The panel has a **session-root picker**: `/api/session-surgeon/roots` offers `$DSH_SESSION_ROOT`, `$DSH_HOME/sessions`, `~/.dsh/sessions`, any sibling directory under `$HOME` that has a DSH home marker plus a `sessions/` dir, and any bare `sessions/` dir — discovery is by **shape, not name**, so `~/custom-lib` or a moved `~/sessions` is found too. Each candidate shows its session count; `其它目录…` accepts any path the user types (with `~` expansion); the choice is remembered in `localStorage`, and when the host's own root is empty while another library has sessions the panel switches with a visible note instead of showing nothing. On this machine that is `~/.dsh-surgeon-dev/sessions` (5 sessions) vs `~/.dsh/sessions` (missing), switchable without restarting the host or exporting variables. - Every session-scoped panel request now names the picked root. Before this, inspect / repair / repair-apply / export / transcript silently used the server default, so switching the picker and then acting on the visible session answered `session not found`. - The session root now follows the host instead of assuming `~/.dsh`: `$DSH_SESSION_ROOT` → `$DSH_HOME/sessions` → `~/.dsh/sessions`. A dev host started with `DSH_HOME=~/.dsh-surgeon-dev` (the documented setup) put its sessions under that home, and the panel listed nothing at all. The panel also stops failing silently: it shows the directory it scanned, the session count, and the read error (with `DSH_SESSION_ROOT` as the override hint) instead of the bare “nothing scanned yet”. - Shipped `fixtures/probes/dangling-tool-call.json` + `run.mjs`: the six-event closed-step shape from [#4549](https://github.com/deepseek-ai/deepseek-harness/discussions/4549), run against the real 0.1.7-rc.1 packages. The v0→v1 and v3→v4 **migration stages accept** it while `restoreReleasedV2Artifact` / `restoreReleasedV4Artifact` (what publishing a v3→v4 migration runs) **refuse it with `step/end leaves unresolved tool call c1`** — so the same damage can surface as a 400 at request time or as an unopenable session, depending on the edge. `dangling-tool-call` copy now says so; repair still only reports, because a `tool/result` can only be written while the step is open (upstream position: `rejected/simplification/2026-06-20-truncate-interrupted-turns.md`). - The probe has a **fifth row**, and the copy was corrected with it: a log already at the current generation (v4) is refused by the **ordinary read path** too — `readStoredLog` → `SessionLogScanner.finish()` → `assertReleasedV4Relationships`, wrapped as `SessionPersistenceCorruptionError: stored log is corrupt`. Older generations still refuse at the restore/publish step, and the inner message is identical on both paths, so only the outer wrapper names the entry point. Measured on **0.1.7-rc.1 and 0.1.7-rc.2** (the same five rows on both; `next` moved to rc.2, and the known gates — descriptor version, plugin-source `form`, retired `instruction-hint`, inserted-message `id`/`role` — are all still present there). Consequently `dangling-tool-call` no longer says "the file opens": on 0.1.7 such a session may not open at all. - The probe runner re-parses the fixture for **every** entry point, and the test runs it end to end when the official packages resolve. The official `restore.decodeRow()` rewrites the row objects it is handed in place, so a pipeline that judges "before repair" and "after repair" from one parsed batch can reach a silently wrong verdict ([#6559](https://github.com/deepseek-ai/deepseek-harness/discussions/6559) reproduction; surgeon itself never calls the official decoders). ### Not in scope (still refuse / warn only) - `v4-literal-plugin-source`: a log already at **v4** whose message source still carries the retired `{kind:"plugin", plugin}` wrapper is refused by the official reader on the ordinary read path (`format v4 message requires a producer-owned source kind` — measured on 0.1.7-rc.1: the same row with `kind:"plugin:dsh-mnemon"` reads cleanly). v4 wants the producer's own kind, which the v3→v4 stage derives from the package name; for an unknown plugin there is no single answer, so inspect reports the rows and repair does not touch them ([#7772](https://github.com/deepseek-ai/deepseek-harness/discussions/7772)). Below v4 the wrapper is legal and the stage lifts it, so the report is gated on this file's own header generation. - Empty `callId` / empty `tool_calls[].id` / dangling `tool/call`: inspect warns (`empty-tool-call-id` / `dangling-tool-call`), repair does not invent a `tool/result` or callId. - Unknown plugin event types: report `unknown-type`, do not stamp `ignorable`. - Web Chat `received more than one start Match`: frontend fold, not a disk defect. - Arbitrary dual-write branch picking when the packed/live-writer signatures do not match. ## 0.1.0 - First public plugin: copy session ID, scan / inspect / dry-run repair / apply / compact / export. - Host tools `session_scan` / `session_inspect` / `session_repair`.