SLOPSHOPPER

replay-theater

Step through the last turn's edits, one diff at a time.

newpanebandguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater
│ ┃ Replay Theater ✕ › fix the failing auth test and add an audit log call │ ┃ Replay Theater 1 2 3 │ ┃ /work/app/src/auth.ts (step 1 of 3) ⏺ Read(src/auth.ts) │ ┃ - if (!claims) throw new Error('invalid ⎿ Read 6 lines │ ┃ token') ⏺ Update(src/auth.ts) │ ┃ + if (!claims || claims.exp < Date.now() / ⎿ Added 2 lines, removed 1 line │ ┃ 1000) throw new Error('invalid token') ⏺ Bash(bun test) │ ┃ + await audit('refresh', claims.sub) ⎿ 3 pass, 1 fail │ ┃ [ Prev ] [ Next ] [ Close ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ ⎿ replay-theater: Replaying │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay Theater
Replay Theater 1 2 3 /work/app/src/auth.ts (step 1 of 3) - if (!claims) throw new Error('invalid token') + if (!claims || claims.exp < Date.now() / 1000) throw new Error('invalid token') + await audit('refresh', claims.sub) [ Prev ] [ Next ] [ Close ]
README

DeepSeek Harness — Community Fork

English | 中文

Español

A self-service distribution of DeepSeek Harness (dsh) for home servers and LAN deployments. It adds a one-command Docker setup, local-network and reverse-proxy access, and declarative environment configuration — without rewriting upstream code, so git merge upstream/master stays cheap.

Safety notice: DeepSeek Harness executes model-generated code. Read SAFETY.md before exposing it to your network, and only trust hosts you control.

What this fork changes

Upstream binds to 127.0.0.1 only and rejects LAN or proxy access by design. This fork keeps upstream's security model but makes it declarative:

  • LAN access (http://<your-ip>:3080) through an explicit trusted-hosts allowlist (DSH_TRUSTED_HOSTS) instead of hard-coded 403 rejections.
  • Reverse-proxy support (Nginx, Caddy, Traefik, Cloudflare Tunnel): forward X-Forwarded-Host / X-Forwarded-Proto and the trust fence and session cookies follow the browser-facing authority.
  • Settings UI unlocked for clients on trusted hosts — not just localhost.
  • Built-in Dynamic Port Proxy (/proxy/<port>/): view and interact with web servers, frontends, and preview apps started by the agent on any internal port (e.g. http://<server-ip>:3080/proxy/8210/, 5173, 3000) through the single DSH port without opening extra Docker ports.
  • Headless Browser & Visual Inspection: the Docker image includes Chromium, system graphics libraries, and pre-installed Playwright so agents can run headless browsers, take WebGL screenshots, and visually self-verify out of the box.
  • Docker-native plugin management: pnpm is preinstalled and its store persists on the /data volume.
  • Structured diagnostics: rejected requests log an exact, credential-free reason (untrusted host "…", origin mismatch (…), session cookie expired at …) to docker compose logs.
  • Telemetry denied by default: the OTel session telemetry, the Desktop product analytics, and the non-inference metadata carried on official DeepSeek API requests (dsh_session_log, dsh_plugin_packages) all ship off, and no collector URL is baked in. Each has an explicit opt-in, and DSH_TELEMETRY_DISABLED overrides all of them. See Telemetry and privacy.
  • Spanish interface: the Web UI ships English, Chinese, and Spanish. It follows your browser language, and Settings pins an explicit choice.

Everything else — the agent loop, plugins, session storage — is upstream code, unmodified.

<a id="run"></a>

Run

Run with Docker

See Quick start (Docker) above.

Run from source

See Running from source (no Docker) below.

Quick start (Docker)

Requirements: Docker Engine 24+ and Docker Compose v2.

git clone https://github.com/samuelrubiodev/deepseek-harness-community.git
cd deepseek-harness-community
cp .env.example .env
docker compose up -d --build

Open http://<server-ip>:3080 and paste your DEEPSEEK_API_KEY in the onboarding dialog (or set it in .env first). The first build compiles the TypeScript monorepo and takes a few minutes; later starts are immediate.

Prefer not to build? The fork publishes multi-arch (amd64/arm64) images to GitHub Container Registry (GHCR) at ghcr.io/samuelrubiodev/deepseek-harness-community with tags :stable (latest tagged release), :latest (latest build from default branch master), and :dsh-v<version> (pinned release versions, e.g. dsh-v0.1.7-alpha.2-community.1); the templates in deploy/nas/ pull from it with no login, no checkout, and no build — designed for NAS hosts (Synology, Unraid, TrueNAS) and servers.

Two volumes persist all state across upgrades and container recreation:

VolumeMountContents
dsh-data/data$DSH_HOME: sessions, profiles, plugins, credentials, settings
dsh-workspace/workspaceThe directory the agent works in and your projects

Check health and logs (expect Up (healthy)):

docker compose ps
docker compose logs -f harness

Running on a NAS (Synology, Unraid, TrueNAS) or a server without a build toolchain? Use the templates in deploy/nas/ and load a prebuilt image. Day-2 operations — backup of /data, restore, update pinning, and rollback — are scripted in deploy/operations/.

Configuration

All knobs are environment variables, documented exhaustively in .env.example. Copy it to .env and restart with docker compose up -d.

VariableDefaultPurpose
DSH_HOST0.0.0.0Interface the server binds to inside the container.
DSH_PORT3080Listening port (also mapped by Compose).
DSH_TRUSTED_HOSTS(empty)Comma-separated hostnames/IPs allowed to reach the Web UI, e.g. 192.168.1.50,harness.lan. Requests with any other Host header get 403.
DSH_REVERSE_PROXYfalseSet true behind Nginx/Caddy/Traefik/tunnels: the proxy's X-Forwarded-Host / X-Forwarded-Proto then drive trust and cookie authority.
DSH_AUTH_MODEtokentoken: sign-in requires /?token=…. none: no token or cookie — only DSH_TRUSTED_HOSTS gates access (see below).
DSH_AUTH_TOKEN(empty)Fixed sign-in token replacing the random per-start launch token, so your URL survives restarts.
DEEPSEEK_API_KEY(empty)DeepSeek API key; can also be entered in the Web UI.
DSH_HOME/dataDurable state root inside the container.

DSH_* variables are process-level bootstrap configuration: Compose injects them natively, and the layered env loader rejects them inside project .env files — put them in the repository root .env (or the Compose environment: block), never in /workspace/.env.

LAN access

Add every address users type into the browser to DSH_TRUSTED_HOSTS, then restart:

DSH_TRUSTED_HOSTS=192.168.1.50,harness.lan docker compose up -d

Hosts on that list also get the persistent Settings panel in the Web UI.

Sign-in token

By default each start mints a random launch token and announces http://<host>:<port>/?token=… — read it from the logs every time you restart. Two ways to stop hunting for it:

# 1. Stable URL: set your own token once; the sign-in link never changes again.
DSH_AUTH_TOKEN=my-long-random-secret docker compose up -d
# open http://192.168.1.50:3080/?token=my-long-random-secret

# 2. No token at all: any host on the trust fence reaches the UI directly.
DSH_AUTH_MODE=none docker compose up -d

With DSH_AUTH_MODE=none the Web UI — including the agent's code-execution tools — is reachable by every machine whose address passes DSH_TRUSTED_HOSTS. Use it only on networks you fully control; the Host/Origin trust fence (section above) remains active either way, and this fork's Docker image logs a warning at startup when the mode is off.

Reverse proxy

Reference configurations with TLS termination, WebSocket passthrough (/api/remote.mux), streaming-friendly buffering off, and long timeouts live in deploy/reverse-proxy/ for Nginx, Caddy, Traefik, and Cloudflare Tunnel. Minimum contract for any proxy:

  1. Set DSH_REVERSE_PROXY=true and add the public hostname to DSH_TRUSTED_HOSTS.
  2. Forward X-Forwarded-Host: $host and X-Forwarded-Proto: https (at TLS-terminating proxies).
  3. Pass Upgrade / Connection headers and disable response buffering.

<a id="telemetry-and-privacy"></a>

Telemetry and privacy

Nothing leaves your deployment unless you opt in. Every telemetry channel and every non-inference contribution to official DeepSeek API requests is denied by default, and no collector URL is baked in.

VariableDefaultPurpose
DSH_TELEMETRY_ENABLED(empty)Opt in to the OpenTelemetry channels. Session telemetry additionally needs DSH_TELEMETRY_MODE=FEEDBACK_ONLY and DSH_TELEMETRY_OTLP_URL; Desktop product analytics additionally needs DSH_PRODUCT_ANALYTICS_OTLP_URL.
DSH_TELEMETRY_MODEDISABLEDSession telemetry sharing policy. FEEDBACK_ONLY releases the canonical session prefix only after new explicit feedback; FULL is rejected.
DSH_TELEMETRY_OTLP_URL(empty)OTLP logs endpoint for session telemetry. An uploading mode requires it, and an opted-in process without it fails at load.
DSH_PRODUCT_ANALYTICS_OTLP_URL(empty)OTLP logs endpoint for Desktop product analytics.
DSH_SESSION_LOG_UPLOAD(empty)Opt in to attaching the canonical session log to official DeepSeek API requests.
DSH_PLUGIN_INVENTORY_UPLOAD(empty)Opt in to attaching the installed plugin inventory to official DeepSeek API requests.
DSH_TELEMETRY_DISABLED(empty)Overrides every opt-in above. Any non-empty value denies, including 0 and false.

The DeepSeek inference path is unaffected: api.deepseek.com requests, the harness identity request headers, and user-agent behave the same whether or not telemetry is enabled.

Upgrading

./scripts/sync-upstream.sh --check
./scripts/sync-upstream.sh --merge

The sync tool and its conflict-resolution runbook are documented in deploy/sync/README.md. Before merging, pin a rollback point and back up your data; the merge itself never touches /data:

./deploy/operations/update-image.sh save
./deploy/operations/backup-data.sh --service

Recreate after building (docker compose up -d --build), and if the new image misbehaves, move the tag back with ./deploy/operations/update-image.sh rollback. Full update, backup, restore, and rollback procedures — plus NAS deployment templates — are in deploy/operations/README.md.

Running from source (no Docker)

Same requirements as upstream: Node.js ^22.19 or 24 and pnpm 11.

pnpm install
pnpm run build
DSH_HOST=0.0.0.0 DSH_TRUSTED_HOSTS=192.168.1.50 pnpm dsh web --no-open

--host 0.0.0.0 prints a safety warning and binds all interfaces; combine it with DSH_TRUSTED_HOSTS to decide who may connect.

Troubleshooting

Read the rejection reason from the logs first — every 403/401 names its exact cause:

Log messageCauseFix
untrusted host "…", trustedHosts: (…)Host header not on the allowlistAdd the host to DSH_TRUSTED_HOSTS and restart
origin mismatch ("https://…" vs "http://…")TLS terminated at the proxy but X-Forwarded-Proto not forwardedSet proxy_set_header X-Forwarded-Proto https;
session cookie authority mismatchCookie minted for a different host/portReopen the startup URL through the same proxy authority
session cookie expired at …30-day cookie lifetime elapsedReopen the URL printed by dsh web to re-authenticate

Healthcheck: the container probes http://127.0.0.1:<port>/ and treats 200/303/401 as healthy — a 401 is the expected unauthenticated challenge, proving the HTTP server and Cordis runtime are alive.

Repository layout (fork-specific)

docker/                    Dockerfile, entrypoint, healthcheck, Cordis bind patch
docker-compose.yml         Production-ready orchestrator (uses .env)
.env.example               Exhaustive declarative configuration template
deploy/reverse-proxy/      Reference Nginx / Caddy / Traefik / Tunnel configs
deploy/nas/                Synology / Unraid / TrueNAS / server Compose templates
deploy/operations/         Backup, restore, update and rollback scripts and guide
deploy/sync/               Upstream sync runbook
scripts/sync-upstream.sh   Automated upstream merge with conflict simulation
.github/workflows/docker-publish.yml  Multi-arch image publishing to GHCR
deploy/lab/                Reproducible test lab (proxy scenarios, WebSockets, SSL)

Upstream packages/, apps/, and documentation are unmodified except for the trusted-hosts, reverse-proxy, and diagnostics features described above.

Community and support

  • Submit feedback or bug reports through GitHub Discussions. Fork-specific issues go to this repository's issues.
  • Add the dsh-plugin topic to your plugin repository for discoverability.
  • Join <a href="https://discord.gg/4MrtZUhpxg">DeepSeek Harness Discord community</a>.

Contributing

See CONTRIBUTING.md.

Development

Start with the development guide and architecture documentation.

pnpm run dev:web builds, serves, and rebuilds client bundles on source edits in one terminal, and make help lists the matching Make targets for Web and Desktop; the guide's application commands section owns the full table.

For agents, follow AGENTS.md.

Citation

@misc{deepseek-harness2026,
  title={DeepSeek Harness: Everything is a Plugin},
  author={DeepSeek-AI},
  year={2026},
  publisher={GitHub},
  howpublished={\url{https://github.com/deepseek-ai/deepseek-harness}},
}

License

MIT, matching upstream. Third-party notices: THIRD_PARTY_NOTICES.md.

Source 1 files
hooks/replay-theater.mjs 134 lines
1// Replay Theater, from "Getting started with Claude Code mods"
2// (https://claude.dev/blog/getting-started-with-claude-code-mods/, Anthropic, 2026-10-01).
3// The post publishes the five hooks that record edits and register /replay
4// unchanged; the rest of the module (stepsFor, the diff, openReplay, the pane
5// and the hint) is completed to the post's description, marked where it starts.
6
7// Replay Theater: step through the last turn's edits, one diff at a time.
8
9// —— completed to the post's description (not in the published excerpt) ——
10
11const EDIT_TOOLS = new Set(["Edit", "Write"]);
12
13// Edits of the running turn, the sealed replay of the last one, and where the pane is.
14const state = { pending: [], replay: [], index: 0, open: false, placed: false };
15
16export function register(on) {
17  on("tool.call", async ($, e, next) => {
18    if (EDIT_TOOLS.has(e.tool)) state.pending.push(...(await stepsFor($, e)));  // old/new text → diff
19    return next(e);                                                              // the edit runs untouched
20  });
21
22  on("turn.start", ($, e, next) => { if (!e.agentId) state.pending = []; return next(e); });
23
24  on("turn.complete", async ($, e, next) => {
25    const r = await next(e);
26    if (!e.agentId && state.pending.length) state.replay = state.pending;       // one replay per turn
27    return r;
28  });
29
30  on("session.start", async ($, e, next) => {
31    const r = await next(e);
32    await $.command.register({ name: "replay", description: "Step through the last turn's file edits" });
33    return r;
34  });
35  on("command.run", { command: "replay" }, async ($, e) => ({ text: (await openReplay($)) ? "Replaying" : "No edits" }));
36
37  // —— completed to the post's description (not in the published excerpt) ——
38
39  on("ui.render", { component: "Pane" }, ($, e, next) => {
40    if (e.requestId !== "replay" || !state.open) return next(e);
41    return theater($, e);
42  });
43
44  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
45    if (state.open && !state.placed) return theater($, e);          // no pane could be placed: the same tree, inline
46    if (state.open || state.replay.length === 0) return next(e);
47    const { Box, Text, Button } = $.ui.resolve(e);
48    const n = state.replay.length;
49    return Box({
50      flexDirection: "row",
51      paddingX: 1,
52      gap: 1,
53      children: [
54        Text({ color: "magenta", bold: true, children: "Replay Theater" }),
55        Text({ dimColor: true, children: `${n} edit${n === 1 ? "" : "s"} this turn — press r or type /replay` }),
56        Button({ label: "Replay", hotkey: "r", onPress: () => openReplay($) }),
57      ],
58    });
59  });
60}
61
62// One replay step per edit: the file, and the text before and after.
63async function stepsFor($, e) {
64  const file = String(e.file_path ?? "");
65  if (e.tool === "Edit") {
66    return [{ file, before: String(e.old_string ?? ""), after: String(e.new_string ?? "") }];
67  }
68  // For a Write, the old contents are read just before the write lands, so the diff is real.
69  const before = await $.fs.read(file).catch(() => "");
70  return [{ file, before, after: String(e.content ?? "") }];
71}
72
73async function openReplay($) {
74  if (state.replay.length === 0) return false;
75  state.index = 0;
76  state.open = true;
77  const opened = await $.ui.open({ id: "replay", title: "Replay Theater", focus: true });
78  state.placed = opened.isPlaced;
79  $.ui.invalidate("ui.render");
80  return true;
81}
82
83function step(delta, $) {
84  state.index = Math.min(Math.max(state.index + delta, 0), state.replay.length - 1);
85  $.ui.invalidate("ui.render");
86}
87
88async function close($) {
89  state.open = false;
90  await $.ui.close({ id: "replay" });
91  $.ui.invalidate("ui.render");
92}
93
94// Lines the edit removed and added, as a unified diff shows them.
95function diffLines(before, after) {
96  const old = before.split("\n");
97  const neu = after.split("\n");
98  const common = new Set(neu);
99  const commonOld = new Set(old);
100  return [
101    ...old.filter((line) => !common.has(line)).map((line) => ({ sign: "-", line })),
102    ...neu.filter((line) => !commonOld.has(line)).map((line) => ({ sign: "+", line })),
103  ];
104}
105
106function theater($, e) {
107  const { Box, Text, Button } = $.ui.resolve(e);
108  const current = state.replay[state.index];
109  const strip = state.replay.map((_, i) => Text({ color: i === state.index ? "magenta" : undefined, bold: i === state.index, children: ` ${i + 1} ` }));
110  const diff = diffLines(current.before, current.after).map(({ sign, line }) =>
111    Text({ color: sign === "+" ? "green" : "red", children: `${sign} ${line}` }),
112  );
113  return Box({
114    flexDirection: "column",
115    border: true,
116    borderColor: "magenta",
117    paddingX: 1,
118    children: [
119      Box({ flexDirection: "row", children: [Text({ bold: true, children: "Replay Theater " }), ...strip] }),
120      Text({ dimColor: true, children: `${current.file}  (step ${state.index + 1} of ${state.replay.length})` }),
121      ...diff,
122      Box({
123        flexDirection: "row",
124        gap: 2,
125        children: [
126          Button({ label: "Prev", hotkey: "p", disabled: state.index === 0, onPress: () => step(-1, $) }),
127          Button({ label: "Next", hotkey: "n", disabled: state.index >= state.replay.length - 1, onPress: () => step(1, $) }),
128          Button({ label: "Close", hotkey: "q", onPress: () => close($) }),
129        ],
130      }),
131    ],
132  });
133}
134