﻿# dupguard · real-time repetition guard for DeepSeek Harness

[中文](README.md) | English

[![npm version](https://img.shields.io/npm/v/dsh-dupguard)](https://www.npmjs.com/package/dsh-dupguard)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![CI](https://github.com/zqh260619/dsh-dupguard/actions/workflows/ci.yml/badge.svg)](https://github.com/zqh260619/dsh-dupguard/actions/workflows/ci.yml)
[![dsh-plugin](https://img.shields.io/badge/topic-dsh--plugin-0969da)](https://github.com/topics/dsh-plugin)
[![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/zqh260619/dsh-dupguard)
[![dsh.so risk](https://www.dsh.so/badge/dsh-dupguard.svg)](https://www.dsh.so/artifact/dsh-dupguard/)
[![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)

> **dupguard** — a real-time repetition guard for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness): when the same string repeats **≥ 10 times** in the streamed output (configurable) it opens a **decision card** (output keeps streaming; stop now / allow to continue; without action it stops after both the multiplier and the minimum reaction time are reached; without a notice channel it stops immediately), and the partial answer is committed as a normal assistant message.

<sub>Badges are generated by npm / GitHub / dsh-plugin.org / dsh.so / awesome-dsh-plugin respectively; the version–compatibility matrix for this plugin is in the
[compatibility report](docs/compatibility-report.md). Questions and suggestions are welcome in [GitHub Issues](https://github.com/zqh260619/dsh-dupguard/issues).</sub>

## Screenshots

| Stop notice (you choose whether to continue) | The stopped turn: output stops at the threshold |
| --- | --- |
| ![Stop notice card](screenshots/01-stop-notice.png) | ![The stopped turn](screenshots/02-subagent-stopped.png) |

| Settings page: repeat counts (per-length table mode) | Per-length table: count decided by unit length |
| --- | --- |
| ![Settings page, top half](screenshots/03-settings-repeat-counts.png) | ![Per-length table mode](screenshots/04-settings-length-table.png) |

| Settings page: ignore list and stop notices | Advanced mode (experimental) risk confirmation |
| --- | --- |
| ![Settings page, bottom half](screenshots/05-settings-ignore-list-and-notices.png) | ![Advanced mode risk confirmation](screenshots/06-advanced-mode-confirm.png) |

| System notification (optional, Windows notification area) | |
| --- | --- |
| ![System notification](screenshots/07-system-notification.png) | |

> The display order of these seven images is also declared in [screenshots.json](screenshots.json) (read by plugin marketplaces / stores); workspace paths in the screenshots are redacted.
> The seventh image comes from the desktop (Electron) build, so the notification is attributed to the app itself; in the web build the browser forwards the notification and is credited instead.

## Quick Start

**Prerequisites**: DSH **≥ 0.1.2-rc.1** (one-command install; the host API minimum is ≥ 0.1.1-rc.1), **Node ≥ 20**, and the
name of the profile you are installing into (the examples below use `web`).

```bash
dsh plugin --profile web add dsh-dupguard      # replace web with your profile name
```

**Restart DSH to take effect** (host code is loaded only at process start). Two signs mean the install worked:

1. the settings page gains a `重复守卫` (repetition guard) section whose footer reads `设置通道：ready｜构建 1.13.2｜自动派生：检测窗口 …`;
2. the host log shows `[dupguard] 生效参数：…` (**this line is the only proof that the settings are actually in effect**).

The defaults work out of the box: **≥ 10 consecutive repeats** open the **decision card** (output keeps streaming; without a notice channel or with decisions off it stops immediately; the partial answer is still committed as a normal assistant message), reasoning text is monitored too, and inside **code regions**
(fenced code blocks, inline code and indented code blocks — all three handled alike) the threshold is relaxed **4×**. Tune it on the settings page — **no restart needed**
(values are written back to `cordis.patch.yml`, and the host hot-reads it).

> Desktop (Electron): **install from the app's Settings → Plugins page** (the `desktop` profile is managed exclusively by the app; the CLI is rejected).
> Uninstall / upgrade: `dsh plugin --profile web remove|update dsh-dupguard`; for other installation methods (local checkout via `--patch` mount /
> dynamic plugin / manual patch layer / temporary disable) see [alternative installs](#alternative-installs).

## Compatibility

| Item | Supported range |
| --- | --- |
| Host API | ≥ `0.1.1-rc.1` (declared under `dsh.compatibility` in `package.json`); one-command bundle install needs ≥ `0.1.2-rc.1` |
| Verified with in-repo records | **DSH 0.2.1-rc.2 (CLI / web profile and desktop, re-checked 2026-10-20)**, **DSH 0.2.1-rc.1 (CLI / web profile, re-checked 2026-10-17)**, **DSH 0.2.0-rc.2 (CLI / web profile and desktop)** (see the [compatibility report](docs/compatibility-report.md)); `0.1.7-rc.2`, `0.1.7-rc.1`, `0.1.2-rc.1`, `0.1.1-rc.1` (see the corresponding CHANGELOG entries) |
| Earlier versions (no record in this repo) | `0.1.5-rc.2` and `0.1.6-alpha.2` were verified while in use, but the repository has no record from that time, so they are not counted in the list above |
| Node | ≥ 20 (same as DSH; Node 18 is not supported); the `.mjs` advanced module relies on the runtime's `require(ESM)` support (built in for Node ≥ 20.19 / ≥ 22.12; older versions fall back and warn) |
| Settings namespace | Since 0.1.7 it is the loader entry id (this bundle: `include:dupguard`); ≤ 0.1.6 used `dsh-dupguard`. Since 1.6.0 the route is chosen at runtime, so **there is no need to swap plugin versions per DSH version** |
| Upgrade note | **Since 0.1.7 the settings key changed from `dsh-dupguard:` to `dupguard:`**; after upgrading, save once on the settings page (or move the old key's contents under the new key by hand) |

- Conclusion: **upgrading to 0.2.x (including 0.2.1-rc.2 / rc.1) needs no config change and no version swap**; the desktop build ships the same version as the CLI, so all the API conclusions above apply.
- **Existing configuration carries over as-is**: since 1.0.0 the keys have **only grown, never been renamed** (seven in 1.0.0 → `ignoredChars` in 1.0.2 → `skipCodeBlocks`/`codeBlockMultiplier` in 1.4.0 → `ignoredSubstrings` in 1.7.0 → `thresholdMode`/`thresholdByLength`/`advancedThresholdFile` in 1.8.0), the value ranges have
  **only ever been widened** (`codeBlockMultiplier` from 1–100 to 0–100; everything else unchanged since 1.3.0), and a missing key always falls back to its default. Keys not declared by the current schema (e.g. `fixStandingMountConflict`, typos) are **ignored**
  instead of invalidating the whole settings section ⇒ **configuration saved through the settings page keeps its behaviour after an upgrade** (regression case S23 covers this: keys only grow +
  ranges only widen).
- The client settings section's only static dependencies are `slots` / `locale`; the settings service is **wired in dynamically** via `ctx.inject()` ⇒ **under the three settings
  models it already supports** (`settings.register`, `settingsScope`, typert `remote.settings`) the entry never gets stuck in `pending`; if DSH introduces a
  fourth model, it will still need adapting.
- The evidence list and history for each check (including a report you can paste straight into DSH Discussions):
  [docs/compatibility-report.md](docs/compatibility-report.md).

## Features

- **Per-delta detection**: every `text-delta` is judged as it arrives (a delta may contain several tokens); a hit opens the **decision card** (by default; without a notice channel or with decisions off it stops immediately).
- **How it stops**: end the stream early → upstream `iterator.return()` → in the adapter's `finally`, `consumer.abort()` **cancels the underlying HTTP request**
  (when the remote side stops is up to the server's behaviour); it does **not `abort()` the agent step signal**, and it emits protocol-compliant `block-end` +
  `finish(stop)`, so the generated content is committed as a normal assistant message and the session log is not polluted.
- **Many repetition shapes**: single characters, words, space / newline separated, line by line, and loops after a prefix (whitespace is stripped before detection by default).
- **Reasoning guard**: reasoning text is monitored by default too, so loops in thinking are stopped as well (`monitorReasoning: false` turns this off).
- **Graphical settings page** (npm resident build): a `重复守卫` (repetition guard) section next to "General / Models / Plugins / Agent presets" where you can edit
  the ignore list and all detection parameters visually; changes apply immediately and are persisted. **Three top tabs split the page by task** (similar to dsh-cost-meter):

  | Page | Contents |
  | --- | --- |
  | **Detection** | Mode (simple / per-length table / advanced) + that mode's count source (threshold / three-column table / module path) + threshold multiplier inside code regions; min / max repeat unit length; ignore whitespace / monitor reasoning / monitor tool arguments; ignore list |
  | **Stop & decisions** | Decision-card toggle + pause-display toggle + minimum reaction time + auto-stop multiplier + local no-force-stop toggle + local provider list |
  | **Notices & log** | Stop-notice toggle + system-notification toggle (optional, with test button) + continue-prompt text + stop-log toggle |
- **Stop notice + one-click continue** (npm resident build): every stop raises a **global notice** — regardless of which session you have open — showing
  **the workspace, the session name and the repeated string**, the repeat count and the channel that matched; you then choose whether to "send continue prompt"
  (injected into the stopped session) or "don't send". See [stop notices and continue prompts](#stop-notices-and-continue-prompts).
- **Optional system notification** (npm resident build, off by default): when enabled, a stop while **the page is not in the foreground** (tab in the background, window minimised, or window unfocused) also sends a **browser system notification**
  (Windows notification area / macOS Notification Center); its body is fixed text with no workspace or session name; the first time you enable it the browser asks for permission.
- **Two delivery forms**: the dynamic plugin [plugin/host.js](plugin/host.js) and the npm resident build [lib/index.js](lib/index.js) /
  [lib/client.js](lib/client.js). The detection engine has a single source in [lib/engine.js](lib/engine.js); the dynamic entry is
  generated by `npm run build:host`, and one test suite drives both entries (CI guards both stale generation and behaviour drift).
- **Stop / allow-continue records (persistent log, npm build only)**: every actual stop appends to `~/.dsh/dupgard/stops.jsonl`;
  choosing **Allow to continue** on a decision card appends to `allows.jsonl` in the same directory. Both files share the same
  field format and switch (rotated automatically; `DSH_DUPGUARD_STOP_LOG=off` disables both); see "Stop and allow-continue records".
- **Threshold decision card**: hitting the threshold **does not pause output by default**; a card appears (stop now / allow to continue). Without action, it stops only when the actual repeats reach threshold × multiplier AND the minimum reaction time has passed (see "Decision card"; the dynamic build and hosts without the notice channel fall back to stopping immediately).
- **Built-in DSH compatibility patch** (`fixStandingMountConflict`, on by default; see "Limitations").

**What it catches / what it does not**

| Caught | Not caught |
| --- | --- |
| Single-character / word / line-by-line repetition reaching the threshold | Frequent words in normal text (only **consecutive** repeats count) |
| Repetition separated by spaces or newlines | Repeat counts below the threshold (9 or fewer by default) |
| Loops after a prefix | Tool-call arguments (`monitorToolArguments` is off by default) |
| Repetition in reasoning (on by default) | Markdown table separator rows and long separator lines (`-`, `\|` are on the ignore list by default) |
| Runaway repetition inside code regions (fenced / inline / indented), caught at "threshold × multiplier" | Normal code / test fixtures / ASCII art inside those regions (relaxed 4× by default) |

## Configuration

Defaults are defined in `CONFIG` at the top of both entries (CI checks that behaviour matches). **npm resident build**: everything except `fixStandingMountConflict` can be adjusted in the
settings page's `重复守卫` section and persisted to `<profile>/cordis.patch.yml`, taking effect immediately; the dynamic build always uses the constants.

| Option | Default | Description |
| --- | --- | --- |
| `threshold` | `10` | Trigger threshold with `>=` semantics (the 10th repetition opens the decision card; without a notice channel or with decisions off it stops immediately). Range 2–1000. **Shown on the settings page in simple mode only**; under `table`/`module` it is only the fallback value |
| `thresholdMode` | `'simple'` | `simple` fixed threshold / `table` per-length table / `module` **advanced mode (experimental; a safety confirmation pops up when it is selected)** (settings-page dropdown). `simple` builds no table and takes the original fast path |
| `thresholdByLength` | `''` | Per-length table for `table` mode: the settings page shows a **three-column table** (start, read-only / end / count, 1–16 rows; the first start is 1 and each later start = the previous end + 1), stored as `"<end>:<count>[, …]"`, e.g. `1:40, 2:30, 8:12`. **The last row's end = the maximum detection length**; the old `*:<count>` form is still accepted (migrated to the last row) |
| `advancedThresholdFile` | `''` | **Experimental**: path to the JavaScript file used by `module` mode; it exports `repeatCount(length) -> count` (synchronous). The file runs inside the host process, and selecting advanced mode pops a safety confirmation; see "Advanced" |
| `minUnitLength` | `1` | Minimum repeat unit length, range 1–4096. **In table mode this is a derived value**: it equals the table start (fixed at 1) and is written back to the document by the plugin; you cannot type it in |
| `maxUnitLength` | `80` | Maximum repeat unit length, range 1–8192. **In table mode this is a derived value**: it equals the last row's "end" and is written back by the plugin; you cannot type it in |
| `detectionWindow` | automatic | **Derived value (read-only)**: computed by the plugin and written back to the document; hand-written values are ignored. Formula in "Detection window (derived automatically)" |
| `skipCodeBlocks` | `true` | Master switch for code-region tiering. **⚠ Hidden on the settings page: `false` ≡ `codeBlockMultiplier: 1`**, so express it with the multiplier instead; the key in old configs still behaves as before, and **changing the multiplier clears it automatically** |
| `codeBlockMultiplier` | `4` | Threshold multiplier **applied uniformly inside code regions (fenced / inline / indented)**; labelled "code threshold multiplier" on the settings page. Three tiers: **≥2** judges at "threshold × multiplier" (the higher, the fewer false kills, and the later a runaway loop is caught); **1** is as strict as outside code; **0** disables detection inside regions entirely (and does not stitch regions to their surroundings). Range 0–100 |
| `stripWhitespace` | `true` | Remove whitespace / newlines before detection, so separated repetition such as `x x x` or `x\nx\nx` is recognised |
| `ignoredChars` | `['-', '\|']` | Per-character ignore list (Markdown table separator rows are not counted). Entries must be **single characters** (by Unicode code point); multi-character or empty entries are dropped at runtime with a warning. **On the settings page this field shares one input box with the next one** |
| `ignoredSubstrings` | `[]` | **Substring ignore list (multi-character)**: whole-literal matching (case-sensitive, no regular expressions); a match is **removed as a whole first**, then whitespace stripping and the per-character rules apply, with longer substrings taking priority. Each entry ≤ 64 code points, at most 64 entries. **Cost**: to match across deltas, each block keeps (longest substring − 1) characters out of detection. **On the settings page this field shares one input box with the previous one**: one code point that is not whitespace ⇒ goes to `ignoredChars`; anything else (multi-character substrings, single whitespace characters) ⇒ goes to `ignoredSubstrings`; separate multiple entries with spaces / commas (`- \|` adds two entries, `-\|` is one substring) |
| `monitorReasoning` | `true` | Also monitor reasoning text; set to `false` to detect visible output only |
| `monitorToolArguments` | `false` | Also monitor tool-call arguments (repeated characters are common in JSON / base64; off by default) |
| `fixStandingMountConflict` | `true` | standing-mount conflict patch (on by default; **the defect was reproduced on `0.1.1-rc.1` / `0.1.2-rc.1`**, later versions were not tested one by one; code constant only, see "Limitations") |
| `notifyOnStop` | `true` | Whether to raise a global notice on stop (see the next section). Turning it off only hides notices; stopping behaviour is unchanged |
| `systemNotify` | `false` | Whether to **also send a system notification** on stop (browser Notification → Windows notification area / macOS Notification Center). Only fires while the DSH page is **not in the foreground** (tab in the background, window minimised, or window unfocused); the body is fixed text (no workspace path, session name or repeated content); the first time you turn it on the browser asks for permission. Requires the DSH web page to be open (a background tab is enough) |
| `persistStopLog` | `true` | Whether to write **stops** to `~/.dsh/dupgard/stops.jsonl` and **allow-to-continue** chooses to `allows.jsonl` in the same directory (rotated after 2MB; **npm/resident build only**, one switch controls both). **Redacted by default** (no repeated content / session title / workspace path); set `DSH_DUPGUARD_STOP_LOG_REDACT=0` to record full content. `DSH_DUPGUARD_STOP_LOG=off` is the higher-priority master switch. See "Stop and allow-continue records" |
| `stopDecision` | `true` | Master switch for the threshold decision card. With the notice channel available, hitting the threshold only raises a notice (**output keeps streaming**); you choose stop now / allow to continue. Off → legacy "stop at threshold" |
| `pauseDecisionOutput` | `false` | **Pause display** (optional; npm build + notice channel): the chunk containing the hit is held until you decide; server-side compute is not paused. For large-chunk / bursty local models |
| `localNoForcedStop` | `true` | **No forced stop for local models**: when the provider is in the local list below, only the decision card is shown and **nothing stops automatically**; you can still use "Stop now". Turn it off to auto-stop local models at threshold × multiplier. Without a notice channel, a hit never stops either |
| `localProviderIds` | `'ollama,llama.cpp,llamacpp,llama-cpp,lmstudio,lm-studio,localai,vllm,sglang,mlx,tabby,koboldcpp,text-generation-webui'` | Comma/space separated local provider ids (covers common local inference servers by default); used to tell local / no-API-cost calls apart. Add your custom provider id if needed |
| `decisionTimeoutMs` | `4000` | Minimum reaction time (ms, 1000–15000, step 500): an automatic stop cannot happen earlier; output does not pause by default (with pause-display on, the hit chunk waits for your decision) |
| `stopAtMultiplier` | `2` | Auto-stop multiplier (2–10): stops only when the actual repeats reach threshold × this value **and** the minimum reaction time has passed; if the repetition breaks first, the decision is cancelled |
| `continuePrompt` | `'请从中断处继续，不要重复之前的内容。'` | User message text injected into the session when the user clicks "send continue prompt" in the notice (≤ 500 code points; empty falls back to the default) |

## Advanced

**Semantics of per-length counts**: detection evaluates `need(p)` for **each candidate period p**, and a periodic text is also a legitimate repetition at
**integer multiples of its period**. Therefore **raising only the count for length 1 usually does nothing** (the same `aaaa…` also matches at p=2, 3, …); to genuinely relax short strings you must raise
every small period that can match, e.g. `1:40, 2:40, 3:40, *:10` (39 identical characters do not trigger, 40 do); conversely, long units only need
fewer counts to take effect on their own (if the module returns "length ≥ 20 → 3", a 20-character unit repeating 3 times opens the decision card; without a channel it stops).

**Priority and fallback chain**: `module` loads successfully ⇒ use the module; `module` fails ⇒ **base `threshold`** (one warning, no silent fallback to the table);
`table` looks up each item ⇒ if uncovered use `*` ⇒ otherwise `threshold`.

**Per-length table mode**: the candidate range is decided entirely by the table — **the minimum unit length is fixed at 1 and the maximum unit length = the last row's end**, both derived values
(written back to the document, shown only in the diagnostics line at the bottom of the settings page); the only number editable in the UI is "threshold multiplier inside code blocks". Only when you need more tiers (> 16 rows),
non-monotonic rules or a formula (e.g. `max(3, ceil(200/p))`) must you switch to a module.

**Module mode (experimental)**: the argument is the **cleaned** unit length (Unicode code points) and it returns the required number of consecutive repeats
(integer 2–1000; < 2 or non-integer ⇒ no detection at that length); it must return synchronously. At host start and on settings change it prints a "count policy" summary
(mode, number of covered lengths, count range, longest span) — use it to confirm that the module is really in effect.

> **Settings as an experimental feature**: the dropdown shows `高级模式（实验性）` (advanced mode, experimental). **Switching to advanced mode from any other mode pops a risk confirmation**
> (the same pattern as DSH's "full permissions" dialog): you must tick "I understand the risks and want to continue" before "enable advanced mode" is clickable;
> cancelling keeps the previous mode and writes no settings. Re-selecting while already in advanced mode does not pop it again; leaving and re-entering does.

> ⚠ **Safety note**: the module file is **executed inside the DSH host process** and has the same permissions as DSH — it can read and write files, make network requests or execute
> arbitrary code; the plugin **cannot validate the file contents**, so only point it at sources you fully trust. Any extension works: `.js` / `.cjs` are safest;
> `.mjs` relies on the runtime's `require(ESM)` support (**built in for Node ≥ 20.19 / ≥ 22.12**; older versions fall back and warn);
> **other extensions (such as `.txt`) are also loaded as CommonJS by Node**; `.json` is data only and parsed as JSON, so it cannot export a function.
> Keep it synchronous, cheap and free of side effects: the plugin only clamps the return value, and falls back to the base threshold if loading or calling fails.

**Functional equivalences** (once you know them, you need not agonise over which one to use):

| Equivalence | Explanation |
| --- | --- |
| `skipCodeBlocks: false` ≡ `codeBlockMultiplier: 1` | Both mean "inside blocks is as strict as outside". The toggle is hidden; express it via the multiplier (**0** = no detection inside blocks / **1** = tiering off / **≥2** = relaxed to "threshold × multiplier"), and changing the multiplier clears the old key automatically |
| Single-row table `N:C` ≡ simple mode `threshold: C` + `maxUnitLength: N` | A single-row table's candidate range is just 1..N with a constant count C, and the derived window is the same |
| Constant module `() => C` ≡ simple mode (threshold C) | The module merely expresses the count as a function |
| Step module ≡ per-length table | Tables are capped at 16 rows and must cover contiguously; only more tiers, non-monotonic rules or formulas force a module |
| Single-character `ignoredSubstrings` entry ≡ `ignoredChars` entry | For a single non-whitespace character both **detect the same result** (removal works per character, so the order does not matter), which is why **the settings page offers one input box**: one code point and non-whitespace goes to the character list, everything else to the substring list; the character list is a convenience that is on by default, the substring list additionally supports multi-character substrings |
| `*:<count>` in `thresholdByLength` | Compatibility for hand-edited configs only: the settings page only produces contiguous tables (never `*`) |
| Hand-written `detectionWindow` value | Overwritten by the derived value (the host does not read it); the settings page no longer offers an input |

**Which one to use**: one threshold is enough → **simple mode**; a few tiers and no code → **per-length table**; a formula / non-monotonic rule / long plateau →
**advanced mode (experimental, requires the safety confirmation)**.

**Trial-run it in an isolated environment first (strongly recommended)**: the advanced-mode module runs **inside the host process, with the host's permissions**, so before putting it into a real profile
trial-run it with the sandbox that ships with this repo — it loads the module in a restricted child process, reuses the plugin's real counting code and prints the effective count for every length:

```powershell
npm run sandbox:module -- <your module file>        # file writes / child processes denied, infinite loops cut by a timeout, network unrestricted (see below)
npm run sandbox:selftest                            # verifies the expected verdicts of six built-in samples (CI runs it too)
```

The full "three-layer isolation" recipe (sandbox trial run → dedicated profile + throwaway workspace → separate `DSH_HOME` / WSL) is in
**[docs/advanced-mode-sandbox.md](docs/advanced-mode-sandbox.md)**; the **dedicated profile (`advtest`) and throwaway workspace
`D:\ZQH260619\repos\dupguard-advtest` are already provisioned** — start it with `powershell -File tools\run-advtest.ps1`
(the script clears variables such as `DSH_PROFILE_DIR` inherited from the current DSH session — otherwise the plugin reads the settings of **another profile**, which in practice overwrote the mode to `table`).
Measured effective parameters: `模式=module｜覆盖长度 1000 种｜次数区间 3–20｜最长跨度 3000 字符` (mode = module, 1000 lengths covered, counts 3–20, longest span 3000 characters — exactly the sample module's return values ⇒ the module really was loaded by the host and drove the policy).

**Code-region detection (fenced / inline / indented, all alike)**: all three region kinds use the same
threshold `threshold × codeBlockMultiplier`, with priority **fenced > indented > inline**:

> **What about "long non-faulty repetition" inside code blocks (box drawing, separators, ASCII art)?** Use `codeBlockMultiplier`, **do not** add characters to the ignore list —
> the ignore list is **global** (it would also hide real faults such as "the model spinning on symbols"), whereas the multiplier only affects code regions. Measured (`npm run audit:false-positives`
> wraps real repository sources in fences and replays the detector): the default config false-kills 2 of 260 files (a `─` box-drawing run and a stretch of `a`); adding **any** symbol character
> (`= * / . # _ ~ + 0 1`) to the ignore list **reduced nothing at all**; raising the code multiplier from 3 to **4** (today's default; in-code threshold 30→40) brought it down to **0**.

| Region | Enters | Exits / boundary |
|---|---|---|
| **Fenced code block** | line start ≤3 spaces + ≥3 consecutive backticks or `~` (followed by an info string) | a run line of the same character, at least as long as the opener (whitespace only after it); **if unclosed, it extends to the end of the block** |
| **Indented code block** | line start indented ≥4 (a tab counts as 4, `\r` is not counted) **and the previous line is blank**, and the previous non-blank line is not a list-item / block-quote start | the first line that is non-blank and indented <4; blank lines inside the block do not end it. **Indentation after a list item and paragraph continuation lines do not count as code** (a conservative rule that avoids misjudgement) |
| **Inline code** | opened by 1–64 backticks (1, 2, … up to 64 all work) | it must be closed **on the same line** by a backtick run of **equal length**; a newline / more than 256 characters retained / end of block without closure ⇒ **the opener counts as plain text** and the content is detected as usual |

- with multiplier `0`, nothing inside the three region kinds is judged, and **region boundaries clear the detection buffer** (text on either side is not stitched into artificial repetition) —
  that is the price of "no false kills however long the code is"; the default `4` still catches runaway loops at "threshold × 4";
- backticks / indentation inside a fence do not open another region; a ```` ``` ```` indented by 4 spaces is not a fence either (it stays inside the indented code block);
- **CRLF-compatible line endings**: `\r` counts as whitespace (not part of indentation), so fence closing lines, blank lines and indented code blocks in `\r\n` documents are judged correctly line by line;
- inline regions need **bounded retention** (at most 256 characters after the opener are withheld from emission), so single-line inline code longer than 256 characters is judged as plain text
  (a known limitation, see below); an unclosed backtick warns once in the host log;
- if an old config explicitly set `skipCodeBlocks: false`, the settings page shows a one-time hint below the multiplier row (equivalent to multiplier 1).

## Detection window (derived automatically)

`detectionWindow` is computed by the plugin from the current parameters and equals exactly the "strictest repetition span" (**the formula appears only in this section**):

```text
required   = max(plainSpan, policySpan)
plainSpan  = max(threshold, threshold × codeBlockMultiplier) × maxUnitLength
policySpan = max over p of ( p × need(p) × code-region multiplier )
value      = clamp(required, 64, 1048576)
```

- simple mode uses `plainSpan`; policy modes (`table` / `module`) use `policySpan` — **the base threshold is not added on top** (the base threshold only applies when the table does not cover
  a length and there is no `*`, and its contribution is already counted in the policy table);
- with multiplier `0` (no detection inside blocks) it counts as 1;
- policy-mode example: `1:20,2:15,10:10,20:5,*:3` + max unit 1000 + multiplier 0 → **3000** (`*:3 × 1000`),
  not `10 × 1000 = 10000`;
- the result is clamped to **64–1048576**; when clamping happens the settings page separately shows `⚠ 自动窗口需要 N 字符，已达上限 …` (the auto window needs N characters, which exceeds the cap), in which case lower the threshold / code multiplier /
  maximum unit length (or the policy span) — the window itself is not an adjustable option;
- the three derived values are `detectionWindow`, and in table mode `minUnitLength` (= 1) and `maxUnitLength` (= the last row's end); every time settings take effect they are
  **written back to the settings document**, so the config file and the UI show the same, actually effective values;
- the settings page offers no input box and no dedicated row (an uneditable placeholder only makes the UI noisier); the numbers are folded into the diagnostics line at the bottom: `table` mode shows
  `自动派生：检测窗口 9000 · 最大重复单元长度 1000（末行决定）` (auto-derived: detection window 9000 · max repeat unit length 1000, decided by the last row), while `module` mode shows the window value the host wrote back to the document.

## How it works

1. **Intercept**: listen to the `llm/stream` waterfall event (wrapping every streaming model call) and return a wrapped `AsyncIterable`; chunks are accumulated by `chunk.index`,
   so interleaved multi-block output does not interfere.
2. **Cleaning / ignore lists**: first remove whole substrings from the substring list (longest first; tails spanning deltas are covered by the flush at block end) → then strip whitespace (can be disabled) →
   finally remove ignored characters one by one. With the substring list enabled, each block keeps at most (longest substring − 1) characters out of detection.
3. **Tail detection**: run **tail-consecutive-repetition detection** on the cleaned buffer — a trigger happens when the text ends with a run of at least `need(p)` consecutive repeats
   of a unit whose length is `minUnitLength`..`maxUnitLength`. A repetition loop shows up as **a consecutive repetition at the tail** of the streamed output, so tail detection catches it while it happens,
   and it also avoids the false positives of whole-window word-frequency counting (such as the frequent Chinese particle "的").
4. **Decision and wrap-up**: a hit opens the decision card by default (output keeps streaming; stop now / allow; without action it stops only after both the multiplier and the minimum reaction time are reached);
   without a notice channel or with decisions off it stops immediately. **When generation is stopped**, end the stream early → upstream `iterator.return()` → in the adapter's `finally`,
   `consumer.abort()` cancels the underlying HTTP request; at the same time emit `block-end` for every open block (carrying the full generated text) and `finish{kind:'stop'}`,
   satisfying the `llm-invariant` checks, and the agent-loop commits what was generated as a normal assistant message. It does **not `abort()` `options.signal` directly**
   (for loop requests that is the agent step signal). Stopping does not wait for network cancellation: with the real adapter, stopping completes in about 1 ms
   (see `tests/experiment-cancel.mjs`).

## What gets stopped

| Shape | Example |
| --- | --- |
| Single-character loop | `aaaaaaaaaa` |
| Word / space-separated repetition | `哈哈` ×10, `hello hello hello …` ×10 |
| Line-by-line repetition (including loops after a prefix) | `好的，下面开始回答：` + `循环` ×10; `抱歉，我无法完成。` ×10 lines |

"Repetition in reasoning" also opens the decision card by default (without a notice channel or with decisions off it stops immediately); the full "what it does not catch" list is in the small table under "Features"
above (only consecutive repeats count, 9 or fewer does not trigger, tool arguments are off by default, table separator rows are ignored by default).

## Stop notices and continue prompts

Every stop (`notifyOnStop` is on by default) pops a notice card in the browser, and the host also prints one line:
`[dupguard] 截停通知 dupguard-stop-N：工作区=… 会话=… 重复="…" ×N`.

**Why it always shows**: the notice is registered inside DSH's **framework-level overlay `shell.overlay` (root scope)**, not inside a session view —
so **whichever session you have open** (even a blank page or the settings page), the notice for the stopped session appears. The stopped session may not be in the foreground at all,
which is exactly why it has to say "who it is".

**Optional: also send a system notification** (`systemNotify`, off by default). The in-app overlay is only visible while the DSH page is open; if you switch to another app,
you will not see it — with this toggle on, a stop also sends a browser system notification (Windows notification area / macOS Notification Center);
clicking it focuses the window and jumps to the stopped session. Four design constraints:

1. **Only fires while the page is not in the foreground** (`document.visibilityState === 'hidden'`, or the window is unfocused `document.hasFocus() === false`) — no repeated interruption while you are looking at the page;
2. **The body is fixed text**, `DSH：模型输出已截停` (DSH: model output stopped) — system notifications appear on the lock screen / in the notification centre, a wider privacy surface than the in-app overlay, so they carry no workspace path, session name or repeated content;
3. **Consecutive stops** in the same session **replace** the previous one via `tag`, instead of stacking up;
4. **The first time** you turn the toggle on, browser permission is requested **within a user gesture**; if denied or unsupported it simply does not fire, and the overlay keeps working.

**Can the desktop (Electron) build show them? Yes.** The desktop UI is a privileged scheme registered as `dsh-app://app` (`standard + secure`),
so it is a secure context as well; and the desktop's permission handling **approves every non-media permission** (`setPermissionCheckHandler` returns `true`,
`setPermissionRequestHandler` calls `callback(true)` directly) ⇒ system notification permission is automatically "granted" ✓, with no permission dialog.
The desktop build itself uses Electron `Notification` for forced-update reminders, through the same Windows toast channel (the attribution is the app, not the browser) ✓.
Two practical notes:

- **It only fires when the window is not in the foreground**: minimising, switching to another tab, or switching to another window (the browser window is still visible but unfocused) all trigger it;
  it does not fire while the page is visible and focused, to avoid interrupting you while you are looking at the overlay. When the browser is fully closed there is no page to send a notification — an inherent constraint of browser page notifications;
- the desktop build's plugins are managed exclusively by the app (the CLI is rejected), so **update inside the app's Settings → Plugins** to a version that has this feature.

**Notice fields at minimum**:

| Field | Source | When missing |
| --- | --- | --- |
| **Workspace** (name + path) | session `header.cwd` + the workspace registry's name matched by path | path only; if there is no path either, `（未知工作区）` (unknown workspace) |
| **Session name** | the `sessionTitle` service's title projection | `（未命名会话）` (untitled session) + a short session id |
| **Repeated string** | the matched unit's original text (monospace + quotes; > 160 code points truncated, with the real length kept) | empty / pure whitespace shows `（空字符串/纯空白）` (empty / whitespace only) |
| Repeat count / span / matched channel | the `count` / `span` at the hit / text or reasoning or tool arguments | — |
| Inside a code region? | a hit inside any of the three code-region kinds (fenced / inline / indented) is marked `已按倍数放宽` (relaxed by the multiplier) | — |

**Three actions**:

1. **Send continue prompt** — the host injects a user message (content = `continuePrompt`) into **the stopped session**;
   that session must still be running; on success the card is removed from the UI, on failure (e.g. the session is closed) the reason is shown on the card and **the card stays** for you to handle manually;
2. **Don't send** — only records your choice, injects nothing;
3. **Open that session** — jumps to the stopped session (client `uiWorkspace.openSession`; the button is hidden when unavailable).

**Boundaries and costs** (all covered by tests):

- each stop produces one notice, and the queue keeps at most the latest **20**; processed ones (sent / ignored) **are no longer shown**, even after a page refresh;
- the notice channel is a host built-in same-origin HTTP route (`/dsh-dupguard/…`): **cross-site requests are rejected**, the request body is size-capped, and responses are not cached;
  clicking "send continue prompt" repeatedly for the same session is **idempotent** (injected once);
- when the host has no `webServer` service (too old a DSH version) the notice is unavailable and only warns once in the host log — **stopping itself is unaffected**;
- host code is loaded at process start: **changing this feature requires restarting `dsh web`**; changing only settings (including toggles and text) does not.
  **Without a restart it is not silent**: a hint card appears in the bottom-right corner of the page (saying the host half has not been loaded yet); click `知道了` (got it) to dismiss it, and it disappears automatically after the host restarts;
- the injected message is an ordinary user message and enters the session history normally (subject to later compaction / rollback mechanisms); the plugin makes no hidden rewrites.

## Decision card before stopping (output continues)

When `stopDecision` is on (default), `notifyOnStop` is on and the notice channel (webServer) is available, hitting the threshold **does not pause output by default**; it only shows a decision card:

1. Output keeps streaming while the card is shown (idle polling 500ms, so the card usually appears within 1s);
2. The card offers three choices:
   - **Stop now**: the next chunk wraps up immediately (normal stop wrap-up + the usual stop notice with the continue prompt);
   - **Allow to continue**: stop detecting for the rest of this generation;
   - **Dismiss card**: no decision; the auto-stop condition stays armed;
3. **Auto-stop condition = actual repeats reach threshold × `stopAtMultiplier` AND `decisionTimeoutMs` (minimum reaction time) has elapsed since the threshold** — both conditions must hold (equivalent to the later of the two moments):
   - multiplier reached first → keep streaming and wait for the reaction time, then stop if still repeating;
   - reaction time elapsed first → keep watching until the multiplier is reached while still repeating;
   - repetition breaks in between → cancel the decision, no stop;
   - stream ends naturally → no stop.
4. User actions take effect on the next streamed chunk.

**One card per repetition episode**: the decision stage judges continuation by the **occurrences of the unit inside a recent window**, not by strict trailing equality;
so periodic/rotating text (an endless alphabet, for example) is not mistaken for an ended repetition when the tail phase shifts, and it does not flood
the overlay with `recovered` cards; auto-stop counts occurrences in the same window and only cancels once enough genuinely non-repeating content has passed.

**Optional: pause display on a hit (`pauseDecisionOutput`, off by default)**: this pauses **display only**, never server-side compute. The chunk that crossed the threshold is held back;
"stop now" keeps only what was already displayed and discards the held chunk (the block is closed with the truncated text), while "allow to continue" releases it.
Without action, if the multiplier is already reached it stops after the minimum reaction time; otherwise output resumes and the card switches to the watching state above.
It targets large-chunk / bursty local models (Ollama / llama.cpp / vLLM / custom providers — anything that goes through the DSH `llm/stream` waterfall);
the model service has no pause/resume protocol, so generation may continue while display is paused.

**Local models are not force-stopped by default** (`localNoForcedStop` is on): a local model only costs electricity, not API fees, so when the provider is in
`localProviderIds` (common local services such as `ollama`, editable) the plugin only shows the decision card and **never stops automatically**; you can still use
"Stop now" to end the call immediately. Without a notice channel (dynamic build / notices off), a hit does not stop the stream either — the plugin logs one note.
Turn the switch off if you want local models to auto-stop at threshold × multiplier. Detection uses the DSH provider id, so models invoked outside DSH
(for example directly by a script) are not covered.

**Fallbacks** (legacy "stop at threshold"):

- host has no `webServer` (notice channel unavailable), `notifyOnStop` is off, or `stopDecision=false`;
- the dynamic plugin build `plugin/host.js` (no notice channel, does not read settings);
- `DSH_DUPGUARD_DECISION=off` forces the fallback for troubleshooting.

**Latency and cost**: the client polls every 500ms when idle and every 300ms while a decision card exists; by default nothing pauses, so content keeps streaming during the reaction window (at most the generation produced in that window), but there is no pause and no burst.

**Privacy**: decision cards live only in host memory, are never logged or persisted; only the final stop writes the stop log / system notification.

## Stop and allow-continue records (persistent log, npm build only)

The npm/resident build writes two JSONL files with **exactly the same fields and format**:

- `stops.jsonl`: every **actual stop**;
- `allows.jsonl`: every **Allow to continue** choice on a decision card (`decision` is `allowed`).

Default locations:

| File | Windows | macOS / Linux |
| --- | --- | --- |
| Stops | `%USERPROFILE%\.dsh\dupgard\stops.jsonl` | `~/.dsh/dupgard/stops.jsonl` |
| Allow-continue | `%USERPROFILE%\.dsh\dupgard\allows.jsonl` | `~/.dsh/dupgard/allows.jsonl` |

- With `DSH_HOME` set both go under `$DSH_HOME/dupgard/`; the web and desktop profiles share them.
- `DSH_DUPGUARD_STOP_LOG=<path>` changes the stop log location; the allow-continue log is always `allows.jsonl` in that file's directory. `DSH_DUPGUARD_STOP_LOG=off` disables both (`0` / `false` / `no` also work).
- The settings page has a **Record stop / allow-continue logs** switch (`persistStopLog`, on by default), **controlling both files**; `DSH_DUPGUARD_STOP_LOG` is the **higher-priority master switch**.
- **Privacy (redacted by default)**: both files stay local (on POSIX the directory/file are created as `0700`/`0600`); by default they do not store repeated content, session title, workspace path or allow-list entries — only lengths/counts/parameter numbers. Set `DSH_DUPGUARD_STOP_LOG_REDACT=0` only when you need the content for debugging (mind the sharing risk). See [Privacy, safety and scope](#privacy-safety-and-scope).
- Each file rotates to `.1` after **2MB** (one history file per log, so both stay bounded).
- Independent of the notice toggle: with `notifyOnStop` off no notice pops, but actual stops are still recorded. An allow-continue record is written only when a decision card appeared and Allow succeeded (a natural break / stream end / dismissing the card writes no `allows.jsonl`). Write failures degrade silently and **never affect stopping or decisions**.
- **The dynamic plugin build `plugin/host.js` does not write logs**: a dynamic Cordis code body cannot `require('fs')`; use the npm/resident install for persistence.

Fields per line (in the default redacted mode `unit` / `sessionTitle` / `workspacePath` / `workspaceTitle` are `null`, and `params` omits allow-lists and the advanced-module path):

| Field | Meaning |
| --- | --- |
| `time` | ISO timestamp |
| `version` | plugin version that wrote the record |
| `sessionId` / `sessionTitle` | session id and title (`null` when the title cannot be resolved) |
| `workspacePath` / `workspaceTitle` | workspace path and name (same fallback) |
| `provider` / `model` | the model call involved |
| `unit` / `unitLength` / `truncated` | matched unit (truncated at 200 characters, `truncated` flag) |
| `count` / `span` / `source` / `code` | repeat count, span, channel (text / reasoning / tool-arguments), whether inside a code region |
| `decision` | stop source: `user` / `auto` / `threshold`; always `allowed` in `allows.jsonl` |
| `heldMs` | milliseconds from the decision card appearing to the user action / automatic stop |
| `params` | snapshot of the detection parameters at the hit (threshold / window / code multiplier / allow-lists …) |

Examples (stop + allow continue):

```json
{"time":"2026-10-22T10:20:07.123Z","version":"1.13.2","redacted":true,"sessionId":"session-xxx","provider":"deepseek-official","model":"deepseek-flash","unit":null,"unitLength":2,"count":10,"span":20,"decision":"auto","heldMs":4000,"source":"text","params":{"threshold":10,"minUnitLength":1,"maxUnitLength":80}}
{"time":"2026-10-22T10:20:08.123Z","version":"1.13.2","redacted":true,"sessionId":"session-xxx","provider":"ollama","model":"gemma4:12b","unit":null,"unitLength":14,"count":10,"span":140,"decision":"allowed","heldMs":820,"source":"text","params":{"threshold":10,"minUnitLength":1,"maxUnitLength":80}}
```

Quick look at the latest lines (PowerShell; one JSON object per line):

```powershell
Get-Content "$env:USERPROFILE\.dsh\dupgard\stops.jsonl" -Encoding UTF8 -Tail 5
Get-Content "$env:USERPROFILE\.dsh\dupgard\allows.jsonl" -Encoding UTF8 -Tail 5
```

## Troubleshooting

### No notice when generation is stopped?

Check three places in order (the host half is loaded only at process start, so **after changing the plugin you must restart `dsh web`** — refreshing the page is not enough):

1. is there a diagnostics card `截停通知未生效` (stop notice not active) in the bottom-right corner of the page — if yes, the **notice channel** did not start; if no, the channel is up;
2. does the host console show these two lines (printed about 2 seconds after start):
   ```text
   [dupguard] 截停通知通道已注册：/dsh-dupguard/notifications（浏览器端浮层轮询该路径）
   [dupguard] 截停通知自检：webServer=就绪｜sessions=就绪｜sessionTitle=就绪｜workspaceRegistry=就绪｜agents=就绪
   ```
   — if any item says `缺失` (missing), that service was not injected (older DSH may lack `sessionTitle` / `workspaceRegistry`;
   the notice still pops, it just shows placeholder text for the workspace or session name);
3. probe the channel directly (run it in any terminal; JSON back means it is fine):
   ```bash
   node -e "fetch('http://127.0.0.1:3080/dsh-dupguard/notifications').then(r=>r.text()).then(t=>console.log(t.slice(0,200)))"
   ```
   an empty 404 ⇒ the host is still running the old code (restart `dsh web`) or the plugin is not loaded.

### No decision card after hitting the threshold?

Check in order: ① is the "threshold decision card" toggle on in the settings page (on by default) and is the "stop notices" toggle on too — the decision card depends on the notice channel;
② does the host have `webServer` (older DSH / the dynamic plugin build has no channel and falls back to "stop at threshold");
③ if the stream ends very soon after the card appears (the model finishes or breaks the repetition immediately), the card may only show briefly and then become `recovered` (no stop) — that is expected;
④ idle polling is 500ms: a very fast hit may end before you can see the card, but it will not stop just because of that (the auto-stop conditions are not met).

### Settings page has no effect / channel not connected

Check three places first: ① the settings page footer `设置通道：…｜构建 1.13.2｜自动派生：…` (`unavailable` / `loading` never changing ⇒ the channel is not connected;
the build marker not matching the freshly installed version ⇒ the browser loaded an old bundle, refresh the page); ② the host log `[dupguard] 生效参数：…`
(**this line is the authority on whether the settings really took effect**; the values shown on the settings page do not count); ③ the browser console
`[dupguard] remote.settings.describe 返回命名空间：…` (if the list does not contain this plugin ⇒ the namespace is not registered / not projected).

> If the settings page reports "no data for the Dupguard section" while stopping still works and the namespace
> list does not contain this plugin: this is the startup `require(esm)` race of schemastery (fixed in 1.12.2).
> The plugin retries with backoff, so **refresh the settings page** — restarting the host is not required.

Eight root causes (most fixed in the corresponding versions; listed for matching symptoms, see [CHANGELOG.md](CHANGELOG.md)):

1. **Host process not restarted**: `lib/index.js` is loaded only at process start ⇒ refreshing the page shows the new UI and lets you save, but the host still runs the old logic;
   after restarting `dsh web`, trust the "effective parameters" line in the startup log.
2. **Entry id misspelled / duplicated**: the namespace comes from the loader entry id (when misspelled, the settings page cannot find this plugin's entry); coexisting with a manual `insert`
   throws `duplicate loader entry id: dupguard`.
3. **Namespace / key mismatch**: after upgrading to 0.1.7 the old key is no longer read (see the upgrade note under "Compatibility"; just save once again);
   a field without `volatile` makes `describe()` skip the whole entry (showing as "settings service unavailable").
4. **Changing a derived value**: hand-written `detectionWindow`, or `minUnitLength` / `maxUnitLength` in table mode, is overwritten ⇒
   change the threshold / multiplier / last table row instead.
5. **Clamped by the cap**: when `required > 1048576` it is clamped to the cap, and repeat units longer than that cannot be recognised (the settings page shows a ⚠ warning).
6. **`skipCodeBlocks` and the multiplier overriding each other**: an explicit `skipCodeBlocks: false` in an old config is equivalent to multiplier 1 and keeps applying until edited;
   submitting a multiplier clears that old key first (best-effort), so it cannot "stay hidden but still override the multiplier".
7. **Propagation between layers**: the settings page writes `<profile>/cordis.patch.yml`, and changes in that layer are not automatically re-parsed into the running entry fiber;
   the plugin now reads that file itself and layers it over Config (effective on the next streaming call), pushing the result back into its own fiber when the file changes;
   failures only warn and detection continues.
8. **Type / range errors in hand-edited config**: numbers written as strings (quoted in YAML, e.g. `threshold: "12"`), out-of-range values
   (e.g. `maxUnitLength: 100000`), misspelled enum values (e.g. `thresholdMode: legacy`) all make DSH **reject the whole section** at registration ⇒
   the settings page falls back to defaults and the host log shows a schema error. Values written by the settings page itself never have this problem (keys only grow, ranges only widen);
   when editing by hand, copy the README examples (no quotes around numbers).

## Tests & development

```bash
npm test        # first validates the generated dynamic entry, then: 107 functional tests (detector) + 27 decision-card tests (decision) + 58 client tests (client) + 20 stop-notice tests (notify) + 13 persistent-log tests (stop-log)
npm run build:host  # regenerate plugin/host.js after editing lib/engine.js (npm test checks it is up to date)
npm run stress  # four stress suites (including real dsh-llm end to end), see below
npm run stress:ci  # the three deterministic suites (without real dsh-llm); CI's stress job runs this
npm run verify  # one command: npm test + stress + sandbox:selftest + audit:markdown (gate before refactors/releases)
```

**Both entries share one detection engine.** The detection algorithm lives only in [lib/engine.js](lib/engine.js);
`lib/index.js` requires it, while `plugin/host.js` is generated by inlining the engine into
`tools/host-entry.template.js` (a dynamic Cordis code body cannot `require` dependencies). The first step of `npm test`
fails when the generated file is stale, so CI's `test` job guards both generation freshness and identical behaviour.

The same suites drive both entries (`plugin/host.js` is evaluated via `new Function`, `lib/index.js` is loaded via `require`), covering pass-through
integrity, every repetition shape, threshold boundaries, protocol closure, upstream `return()` calls, reasoning / tool arguments not monitored by default, closing of unclosed tool-call
blocks, state isolation across calls, and the settings schema plus hot updates. `client.test.js` drives the settings-page component with a minimal React and DSH client stub and
compares 120 configurations of table parsing / max-unit-length / window derivation against the host implementation (cross-implementation drift guard).

**CI coverage** (`.github/workflows/`):

| Workflow | Trigger | Runs | Why split this way |
| --- | --- | --- | --- |
| `ci.yml` → `test` | push / PR | `npm test` on **Node 20 / 22 / 24**; on Node 22 also `npm run sandbox:selftest` (advanced-mode sandbox self-test) | behaviour and version compatibility (same as DSH; Node 18 unsupported); the sandbox only needs its verdicts checked on one version |
| `ci.yml` → `stress` | push / PR | `npm run stress:ci` (Node 22) | concurrency / worst-case scans / memory / chunking invariance / protocol interleaving / settings churn / high-frequency settings-page interaction — these test **invariants**, so one version is enough; about 48s locally |
| `ci.yml` → `docs` | push / PR | `npm run audit:markdown` (Node 22, no dependencies) | Markdown **rendering** health (`README.md` / `README.en.md` / `CHANGELOG.md` / `docs/*.md`): fence pairing / table column counts / backtick spans / **CJK bold flanking** / programmatic range notation — these errors render as "bold in the wrong place" or "content swallowed", invisible to the eye and to a diff |
| `publish.yml` | `v*` tags / manual | `npm test` → **real DSH contract check** → `npm publish` | the real contract is coupled to the **upstream DSH version**, so it does not suit daily CI and acts as a **pre-release gate**: pinned DSH version (`DSH_GATE_VERSION`, currently `0.2.1-rc.2`), installed best-effort (registry flakiness does not block a release); if installed it must pass, otherwise SKIP with an explicit log line |

> The real-contract suite picks its implementation layer automatically by upstream version: when `dsh-llm` provides `invariant`, that is used; otherwise it uses the real cordis Context
> `llm/stream` waterfall, and output goes through the real BlockAssembler; when neither is available it prints `SKIP` and exits 0.
> It probes `$DSH_LLM_DIR` → `$DSH_INSTALL` → `$DSH_HOME/profiles` → global npm → **in-repo `node_modules`** in that order,
> so after `npm install --no-save @deepseek-ai/dsh@<pinned version>` it can really run on any platform (CI included).
> The release pipeline sets `DSH_GATE_REQUIRE_REAL=1` when installation succeeds: in that case **having no real implementation layer available is an outright failure**,
> so the gate cannot silently stop working. Before bumping the pinned version, run `node tests/stress-real-invariant.mjs` locally once to confirm the upstream contract has not changed.

Four stress suites (`npm run stress`):

| Suite | Coverage |
| --- | --- |
| `tests/stress-host-adversarial.js` | boundaries / overlapping periods / chunking invariance / protocol interleaving / code regions (fenced / inline / indented) and the substring ignore list / settings churn / malformed input |
| `tests/stress-host-throughput.js` | throughput, worst-case scans, hit latency, memory, 200-way concurrency, parameter extremes |
| `tests/stress-client-ui.js` | settings page with 500 ignore-list entries, 1000 mixed operations, out-of-order write responses, churn, mount leaks |
| `tests/stress-real-invariant.mjs` | real DSH contract check (invariant or a real cordis waterfall + real BlockAssembler), 8 cases |

`tests/notify.test.js` covers the full stop-notice path: queue limits and state transitions, workspace / session-title resolution (including Windows
path normalisation and degraded behaviour when services are missing), continue-prompt message construction (preferring DSH's `createUserMessage`, shaping it identically when absent),
the HTTP route's list / action / idempotency / cross-site rejection / invalid requests, and the end-to-end "a real stop → notice enqueued → user clicks send → host injects".

The last suite uses the locally installed `@deepseek-ai/dsh-llm` (probing `$DSH_LLM_DIR`, `$DSH_INSTALL`, `$DSH_HOME`, the global npm
installation, and **in-repo `node_modules`** in that order); when not found it prints SKIP and skips, so it is safe to run anywhere.

## Project layout

```text
.
├── plugin/
│   └── host.js                 # dynamic plugin form (cordis_define's code.host; generated by tools/build-host.mjs — do not edit)
├── locale/
│   ├── en.json                 # plugin-page strings (English; DSH requires en.json for localisation to be enabled)
│   └── zh.json                 # plugin-page strings (Chinese; selected automatically by UI language)
├── lib/
│   ├── engine.js               # detection engine (single source: edit detection logic only here)
│   ├── index.js                # entry orchestration (package.json main: apply / Config / test hooks)
│   ├── settings.js             # settings integration (schema / read-write / derived write-back / legacy settings channel)
│   ├── notify.js               # stop-notice channel (queue / metadata / HTTP route / continue instruction)
│   ├── stop-log.js             # stop / allow-continue persistent logs (JSONL append / rotation / shared switch)
│   └── client.js               # browser-side settings page (ModuleLoader format, dsh.client entry)
├── tools/
│   ├── build-host.mjs          # generates plugin/host.js from lib/engine.js + template (--check is part of npm test)
│   └── host-entry.template.js  # dynamic-entry template (build input, not shipped in the npm package)
├── tests/
│   ├── helpers/report.cjs      # shared ok / bad / metric / summary helper for the stress suites
│   ├── helpers/host-harness.cjs # shared host stub (ctx + upstream stream + text chunks) for notify / stop-log tests
│   ├── detector.test.js        # end-to-end tests: both entries kept in sync + reasoning toggle + settings/Config integration
│   ├── decision.test.js        # threshold decision card: engine state machine + host integration (continuous output/stop/allow/two-condition auto-stop)
│   ├── client.test.js          # settings-page component tests: minimal React/DSH stubs + cross-implementation derivation matrix
│   ├── notify.test.js          # stop-notice tests: queue / metadata resolution / message construction / HTTP route / end-to-end injection
│   ├── stop-log.test.js        # stop / allow-continue log tests: paths / fields / truncation / rotation / switch / apply wiring
│   ├── stress-host-adversarial.js  # stress: boundaries / protocol interleaving / code regions (fenced/inline/indented) and the substring list / hot-update churn / malformed input
│   ├── stress-host-throughput.js   # stress: throughput / memory / 200-way concurrency / parameter extremes (METRIC lines)
│   ├── stress-client-ui.js         # stress: high-frequency settings-page interaction, out-of-order responses, mount leaks
│   ├── stress-real-invariant.mjs   # stress: real DSH contract check (invariant or a real cordis waterfall + real BlockAssembler)
│   ├── experiment-cancel.mjs       # diagnostic experiment (not in CI): proves stopping does not block on cancelling the underlying stream
│   └── experiment-inspect-patch.mjs # diagnostic experiment: multi-generation behaviour of the standing-mount patch
├── docs/
│   └── compatibility-report.md # compatibility report and evidence list (ready to paste into DSH Discussions)
├── .github/workflows/          # ci.yml (npm test on Node 20/22/24) + publish.yml (release npm on v* tags)
├── cordis.patch.yml            # bundle patch layer (dsh.bundle.patch: inserts the host line)
├── package.json
├── CHANGELOG.md
├── LICENSE                     # MIT
├── README.en.md                # English README (language switcher links the two at the top)
└── README.md
```

## Alternative installs

Besides the one-command install in "Quick Start", there are three alternative methods; **keep only one install method in place at a time** — a duplicated entry id makes the loader throw
`duplicate loader entry id: dupguard`.

```bash
dsh plugin --profile web update dsh-dupguard   # upgrade
dsh plugin --profile web remove dsh-dupguard   # uninstall (dependencies and bundles entries are removed too)
```

Why the one-command install needs no hand-edited YAML: this package ships its own bundle patch layer (`dsh.bundle.patch` → [cordis.patch.yml](cordis.patch.yml));
`dsh plugin` forwards the arguments to the pnpm in the profile directory, and after installation it automatically adds dependencies declaring `dsh.bundle` to `dsh.profile.bundles`,
and inserts the host line `{ id: dupguard, name: dsh-dupguard }` (needs DSH ≥ 0.1.2-rc.1).

**① `--patch` mount a local / repository path** (does not touch the profile; handy for development and debugging):

```yaml
# dupguard.patch.yml —— same format as the profile's cordis.patch.yml (a list of patches)
- insert:
    - id: dupguard
      name: file:///path/to/dupguard/lib/index.js   # on Windows: file:///D:/path/to/dupguard/lib/index.js
```

```bash
dsh --profile web --patch ./dupguard.patch.yml
```

`--patch` is `dsh`'s own repeatable option; the overlay is applied after the profile's user layer, so it need not be written into any profile file;
`name` uses a `file:` URL pointing at the in-repo [lib/index.js](lib/index.js) (a CJS `module.exports = { name, apply }`, zero build,
compatible with the loader's `unwrapExports`; relative paths are resolved from the profile directory).

**② Dynamic plugin** (no install, live in-process, feature subset): submit the entire contents of [plugin/host.js](plugin/host.js) as `code.host`
to `cordis_define`, then activate the returned `packageId` with `cordis_run`; it lives as long as the DSH process, so after a restart you must define + run again.
**Feature subset**: no settings page (parameters are fixed to the `CONFIG` at the top of the file); `module` mode is unsupported (`table` works; `module` falls back to the fixed threshold with a warning).

**③ Manual patch layer**: `insert` the line from ① into `<profile>/cordis.patch.yml` (the user layer is applied after the bundle layer, and saving hot-reloads);
to disable temporarily, write `- id: dupguard` + `disabled: true` in that file (saving unloads it, no restart needed).

## Limitations

- Threshold semantics are `>= threshold`: the 10th repetition opens the **decision card** (without a notice channel or with decisions off it stops immediately); 9 or fewer repeats do not trigger.
- If a tool-call block happens to be unclosed when stopping (practically impossible for adapters that emit blocks in order), that block is closed with the parameters accumulated so far and may be executed;
  when the protocol gains new `ContentBlock` types, the stop wrap-up can only fall back to tool-call for unknown block types and prints a one-time warning
  (the `tool-addition` / `tool-removal` blocks added in DSH 0.2.0 carry no deltas, so detection cannot trigger while they are open, and that fallback path is never reached).
- Stopping relies on the adapter's semantics of cancelling the underlying request when the stream closes: the **local cancellation path** has been verified with the real adapter (`tests/experiment-cancel.mjs`:
  stopping still completes in about 1 ms when cancellation hangs for 3 s); **whether the remote side stops immediately is up to the server's behaviour**, and custom adapters should be checked by their authors.
- **Code regions (fenced / inline / indented) are relaxed uniformly by the multiplier**: with multiplier `0`, runaway repetition inside all three region kinds is never stopped, and the model can bypass detection just by
  wrapping its repetition in backticks or 4-space indentation — that is the explicit cost of this mode; the default `4` still catches runaway loops at "threshold × 4" (see "Advanced").
- **Indented code blocks use a conservative heuristic**: they need "line start ≥4 spaces + a preceding blank line + the previous non-blank line is not a list item / block quote"; there is no full CommonMark
  block parsing (block quotes, nested lists, table column widths are not considered); therefore an indented code sample after a list item is treated as plain text.
- **Single-line inline code longer than 256 characters** (the retention cap) falls back to plain-text judging ⇒ repetition inside such over-long inline code is stopped at the normal threshold;
  in exchange, one stray backtick cannot disable detection for the rest of the message.
- **The cost of the substring ignore list**: to match across deltas, when enabled each block keeps at most (longest substring − 1) characters out of detection, i.e. detection is delayed by at most that many
  characters; the limits are 64 entries × 64 characters, replacement is literal per entry and does not support regular expressions; with an empty substring list there is no substring scanning at all (no extra cost).
- The detection window is capped at 1,048,576 characters: every delta appends to and trims the buffer once, so the cost grows linearly with the window. The two numbers below come from one measurement of
  `npm run stress` (`perchunk_*` metrics in `tests/stress-host-throughput.js`) on this machine, and **vary with hardware and Node version — read them as orders of magnitude**: with the default 8192 window, about
  **microseconds per delta**; with the window filled to the 1 MiB cap, about **0.1 ms per delta** (model token intervals are usually milliseconds, so under the default configuration this cost is negligible).
- When an illegal value is written by hand (e.g. `minUnitLength > maxUnitLength`), DSH rejects the namespace at registration; the plugin catches it, logs the error only
  and falls back to the code defaults as a whole (detection is unaffected; the settings page shows the defaults).

**standing-mount conflict (a defect in early DSH versions; this plugin ships an idempotent patch)**: editing the composition file of an already-mounted preset while it is running makes the next resume create a new generation of standing mount
while the old one is never destroyed; `tool-cordis` then registers its provider with the process-global `cordisInspect` again and again, reporting `Host Cordis inspect provider "Service" is already registered`,
and DSH must be restarted to recover. By default the plugin makes `cordisInspect.register` **idempotent** (the same id shares the existing registration), which removes the error; the patch lives in-process and is not stacked by HMR reloads.
**The defect was reproduced on `0.1.1-rc.1` / `0.1.2-rc.1`** (see the corresponding CHANGELOG entries); whether later versions still need the patch has not been tested version by version;
to turn it off, change `CONFIG.fixStandingMountConflict` at the top of `lib/index.js` to `false` (**a code constant, not a setting**).
For the full mechanism, the tested versions and the operational rule "restart after editing a preset at runtime", see [CHANGELOG.md](CHANGELOG.md).

## Privacy, safety and scope

**Privacy**

- **No network calls, no telemetry**: none of the three files — the host (`lib/index.js`), the dynamic entry (`plugin/host.js`) and the client (`lib/client.js`) — makes any network call;
  the client talks to the host over DSH's own settings channel, never through a third-party service.
- It only **reads** local files: `<profile>/cordis.patch.yml` (to apply settings without a restart) and the module file you point advanced mode at.
  The plugin writes two optional files: the **stop log** `~/.dsh/dupgard/stops.jsonl` and the **allow-continue log** `~/.dsh/dupgard/allows.jsonl` (on by default; see below); settings persistence itself is done by DSH's settings service.
- Detection processes only the streamed text **of the current generation** in memory (the visible output, plus reasoning when `monitorReasoning` is on);
  only when the stop log is enabled does it write the **matched fragment (≤200 chars), session title, workspace path/name** and hit parameters to local disk — **never uploaded or shared with any external service**.

**Privacy boundaries of the stop / allow-continue logs**

- Its content is as sensitive as DSH's own session logs (which already contain the full conversation); the difference is that this log is **plaintext JSONL**, easier to open or accidentally share.
- It stays local by default; on Windows it lives inside the user profile (inheriting the user ACL), on POSIX the directory/file are created as `0700` / `0600`.
- If you do not need them, turn off "Record stop / allow-continue logs" in the settings page or set `DSH_DUPGUARD_STOP_LOG=off`; delete existing records by removing `~/.dsh/dupgard/stops.jsonl` / `allows.jsonl`.
- It is **redacted by default**: no repeated content, session title, workspace path or allow-list entries are stored — only lengths/counts/parameter numbers; set `DSH_DUPGUARD_STOP_LOG_REDACT=0` only when you need full content for debugging (mind the sharing risk).
- The logs also record two numeric fields: `decision` (user/auto/threshold; `allowed` in the allow-continue log) and `heldMs` (decision wait); no user content.
- No telemetry and no network calls; the plugin never reads the log back or uploads it (only `tail`/your editor reads it).

**Safety**

- Advanced mode (experimental) **executes your JavaScript file inside the DSH host process**: that file has the same permissions as DSH and can read and write files,
  make network requests or execute arbitrary code; the plugin **cannot validate the file contents**. Switching into that mode pops a risk confirmation —
  only point it at sources you fully trust.

**Scope and maturity**

- Applies to DSH's **CLI** and **desktop** builds; it is not meant for other hosts or standalone Node programs.
- A single-maintainer personal project: every capability claim rests on **the in-repo test suites and local measurements** (see
  [tests & development](#tests--development) and the [compatibility report](docs/compatibility-report.md)); there is no broad environment matrix,
  and anything unverified or experimental is labelled as such in the text.
- Questions, suggestions and compatibility reports: [GitHub Issues](https://github.com/zqh260619/dsh-dupguard/issues).

## License

[MIT](LICENSE)
