XSJUSTC/dsh-rewind

dsh-rewind

DSH (DeepSeek Harness) conversation rewind plugin - rewind to any user message, interrupt a running turn, hide the tail from chat view and model context, cancel freely before sending.

Uncategorizeddsh-plugin-marketdsh-plugins

@xsj/dsh-rewind

English | 简体中文

A conversation-rewind plugin for DSH (DeepSeek Harness). A permanent bundle plugin with a host half and a web client half — zero dependencies, zero build steps.

Features

  • Every user message row gains a ↺ rewind action beside the copy icon. Clicking it:
    • interrupts the current turn if the model is still thinking/streaming;
    • hides that message and everything after it from the chat view (as if the tail never happened);
    • pre-fills the composer with the message text — unsent and editable;
    • re-attaches any images that rode the message back into the composer (the host reads the original bytes from the session log and feeds them back through the official attachment intake, so image count/size limits apply);
    • on the next send, the model only sees the truncated history (everything before the rewind point) plus your new message.
  • While a rewind is pending:
    • a banner above the composer explains the state;
    • a ✕ "cancel rewind" button sits left of the send button — clicking it restores the hidden messages, keeps your draft, and sends nothing.
  • Once sent (rewind committed), the hidden tail never reappears in the chat view and never enters the model context again.
  • Only user messages can be rewound; assistant messages have no rewind entry (the host validates the event type).

Version compatibility

  • v2.4.0 is verified against dsh 0.1.5-rc.2 while keeping earlier 0.1.x hosts working — event-log access prefers the official snapshotEvents() / eventAt() and falls back to session.events on older hosts.
  • v2.4.0 fixes "the model's output was not hidden" (the main fix here):
    • Cause: the plugin shadows only the user / steering renderers of conversation.chat.node, so only those rows ever carried a data-xsj-seq stamp. The model's assistant-step output, tool calls and context rows render through the shipped renderer and were never stamped. The hiding pass treated an unstamped row as "no evidence" and therefore visible, so a committed range hid the prompt while the answer stayed on screen.
    • Fix: an unstamped row is no longer given up on. It inherits the verdict of the nearest stamped row before it, which is the turn it belongs to; only a leading run with no evidence at all falls back to the evidence that follows. The forward attribution matters: "hidden if anything later is hidden" sweeping in the answer above a range is a real regression that was hit during development and is now covered by test/hiding.test.mjs.
    • Restore the DOM on driver teardown: hiding writes to the live DOM, but teardown only cleared the timer and observer. Switching sessions or remounting the composer left the hidden rows for the next view (blank rows out of nowhere). Teardown now releases every row carrying this plugin's marker — and only those, leaving any other feature's display untouched.
    • Tell "no data yet" apart from "no ranges": on a cold load the driver ran before /state answered, and the two were indistinguishable, so every load flashed the rewound tail back into view; if the read failed it stayed visible for good, leaving the UI showing messages the model cannot see. Sessions now track fetched / fetchFailed: while no answer has arrived the current verdicts are held and a retry is scheduled, and a locally recorded mark still applies immediately.
    • Host: agent/disposed no longer strands the patch: ensurePatched installs a deriveMessages override on the session instance closing over st.ranges, and disposal used to drop the state record outright, leaving that override with no record to unwind it — the session kept filtering its model context permanently, surviving a plugin reload. Disposal now follows the same rule as evictIfNeeded: unwind first, then drop.
    • ensurePatched is self-healing: it no longer trusts the st.patched flag alone but compares the installed method against the recorded wrapper and re-installs when they diverge (idempotent), so a flag that drifts out of sync cannot silently stop hiding the tail from the model.
    • Image references cache hits only: a reference, once in the append-only log, can never disappear, so a hit stays valid; a miss cannot, and caching one made /image answer 404 for an id the log does reference — surfaced as an image silently dropped on rewind.
  • Changes in v2.3.0:
    • Rewind now re-attaches images: the host half exposes /api/xsj-rewind/image to read a message's durable image bytes by attachmentId, and the client rebuilds File objects and feeds them back through the composer's hidden file input, inheriting the shipped image count/size validation.
    • Fixed a cascading render crash on image messages: the client half of @deepseek-ai/dsh-client-ui-attachment exports only apply/inject in 0.1.5-rc.2 (no ImageGallery component), so referencing it threw inside any image-bearing user row and the React error boundary widened the damage to the surrounding rows — the rewind button vanished across a whole span of messages. Images now render through the shipped renderMessageImages prop (routed to the conversation.message.images slot).
    • Hardened slot registration: the slots service is resolved defensively and each seat registers under its own try/catch. A single registration conflict (e.g. a client HMR rebuild race) previously aborted apply(), leaving the stylesheet injected with every renderer lost.
    • Safer DOM hide driver: an unstamped row no longer inherits a neighbour's hidden verdict, so a freshly sent message cannot be hidden while its view is still stamping. (Corrected in v2.4.0: that same conservatism made the model's output permanently un-hideable — see above.)
    • Serialized image re-attachment so concurrent rewinds cannot race on the same file input.
    • The host half resolves attachments at call time (ctx.get), so a missing attachment provider no longer blocks the mount; the image endpoint alone degrades to a 501.
    • Resource bounds: the per-session state table and the image-reference cache are capped, and image responses plus attachmentId length are validated.
  • Changes in v2.2.0:
    • Adapted to 0.1.5-rc.2: primitives.MessageText was removed; the user bubble now renders text through the same projectUserText() helper the shipped bubble uses (reference/session chips included);
    • Client inject declaration updated: dropped the retired @deepseek-ai/dsh-client-runtime;
    • Dead code and debug scaffolding removed (debug log file, fuzzy session resolution, two unused code paths).
  • Known symptoms of ≤2.1.2 on dsh 0.1.5-rc.2: user message rows crash while rendering, no rewind button — upgrade to ≥2.3.0.

Security notes

  • The four endpoints (mark / cancel / state / image) register on the dsh web server and therefore follow dsh's own local-trust model: dsh web listens on 127.0.0.1 by default, so they are reachable from this machine only and do not pass through dsh's API authentication layer. If you bind dsh web to a routable address (e.g. 0.0.0.0), these endpoints become reachable too — restrict them at the network layer (firewall or an authenticating reverse proxy) in that case.
  • The /image endpoint is scoped by the session log: it returns bytes only for attachments that session actually references (matched by attachmentId against the event stream). There is no path-based or arbitrary-id read. The bytes come from dsh's attachments.readImage, which verifies integrity.
  • Limits: 64 KB request bodies, 256-character attachment ids, 64 MB per image response.
  • The plugin collects and reports nothing; all state stays in the local session log.

Log & recovery

  • The session log (an append-only event stream) never loses a message.
  • Every state change appends a hook/invoked event with payload { source: 'xsj.rewind', phase: 'mark' | 'cancel' | 'commit', targetSeq, hiddenFrom?, hiddenTo?, preview? }. The type is a known-but-unused reserved entry in this build, so the records are reload-safe.
  • After a process restart, opening a session replays these records to rebuild the hidden ranges — model-side and UI-side hiding stay consistent across restarts.
  • The trajectory view does not render this reserved event type; audit the raw session JSONL log directly.

Install

# from anywhere; the path points at your clone of this repo:
dsh plugin --profile web add <absolute path to this repo>

# restart the DSH process, then refresh the browser page

The command registers the package into the profile (~/.dsh/profiles/<name>/package.json: dependencies + dsh.profile.bundles). At boot the profile merges this package's cordis.patch.yml, inserting the host plugin row; the web client scan then serves lib/client.js automatically.

Uninstall

dsh plugin --profile web remove @xsj/dsh-rewind
# restart DSH. Rewind records in session logs are harmless (hook/invoked is a
# known event type).

How it works

  • Model-side truncation: patches the live Session object's deriveMessages() to skip hidden surface ranges (memoized by a signature of surface size / range count / replace generation). The request builder, the llm/stream reconstruction invariant, and the image check all share this one method, so every reader stays consistent. No message event is ever added, removed, or rewritten.
  • Interrupt: on mark, a running agent is cancelled with agent.cancel({ kind: 'user' }, { keepInbox: true }); queued messages survive and continue from the rewind point.
  • Commit point: inside the agent/pre-step waterfall, the first step that claims a real input message seals the hidden range [targetSeq, current log end]; the new message is appended afterwards and stays visible.
  • UI hiding: purely DOM-driven — chat rows carry data-chat-flow-key; the client stamps each row with its seq and a MutationObserver applies inline display:none to hidden rows. Nothing depends on the host store's internal shape; cancelling or switching sessions restores everything without touching any shipped renderer.
  • Rewind icon: takes over the user/steering cells of conversation.chat.node at priority -1 (the slot system's native shadowing), replicating the native bubble (projectUserText / ImageGallery / Tooltip / writeClipboard) plus the ↺ button.

Porting

The plugin has no npm dependencies: the host half uses only Node builtins and injected services; the client half pulls react, @deepseek-ai/dsh-client-ui-primitives, and @deepseek-ai/dsh-client-ui-attachment from the shell's frozen module table via window.__ModuleLoader__. Clone this repo on any machine and follow "Install".

License

MIT