# dsh-web-enhance

[English](README.md) | [中文](README.zh.md)

[![npm version](https://img.shields.io/npm/v/@yangzhe1991/dsh-web-enhance)](https://www.npmjs.com/package/@yangzhe1991/dsh-web-enhance)
[![npm downloads](https://img.shields.io/npm/dm/@yangzhe1991/dsh-web-enhance)](https://www.npmjs.com/package/@yangzhe1991/dsh-web-enhance)
[![license](https://img.shields.io/github/license/yangzhe1991/dsh-web-enhance)](LICENSE)
[![dsh-plugin](https://img.shields.io/badge/dsh-plugin-1e90ff)](https://github.com/topics/dsh-plugin)

**dsh-web-enhance** is a browser plugin for the [DSH (DeepSeek Harness)](https://github.com/deepseek-ai/deepseek-harness) web UI that adds small quality-of-life features. It currently ships:

- **Thinking chain default-expand** — a floating toggle (the lightbulb button at the bottom-right, on by default) that automatically expands every "Think" reasoning disclosure in the conversation, so the full thinking chain is visible while streaming instead of a one-line summary.
- **Session cost meter** — when a session runs on the official DeepSeek API, an estimated session cost (CNY) is shown at the far left of the same line as the built-in input/output token stats, priced per request at the real peak/off-peak hour using the official pricing page rates.
- **GLM Coding Plan quota badge with a fixed-overhead safety line** — when a session runs on GLM (Coding Plan), the same spot shows the subscription quota badge (e.g. `5h额度18%，周额度35%，周窗口40%，安全线52%，5d15h后重置` — quota usage of both windows, the weekly window's *time* progress, the fixed-overhead safety line, and the weekly reset countdown; the badge text is Chinese). Hover for each window's time-to-reset, the safety-line math and more. The numbers are account-level truth fetched by the host while GLM streams; zero queries otherwise.
- **Open files in the system app** — clicking a file path in the conversation hands it to your desktop default application (VS Code, text editor, …) instead of popping the right sidebar. Can be turned off in Settings → General.
- **Completion alert** — a chime when a session *really* finishes its work (or the moment a dialog waits for your choice), plus a tab-title alert while you are on another tab. On by default; can be turned off in Settings → General. *(Merged in from the retired [dsh-task-notify](https://github.com/yangzhe1991/dsh-task-notify) plugin.)*
- **Dropped-stream keep** — when the provider cuts the streaming connection mid-output (typically GLM's sensitive-word filter), the stock frontend wipes whatever had already streamed. This plugin re-renders the host-persisted "failed attempt" event back into the conversation: thinking disclosures and body text as usual, with the stock "Stopped" badge, and it survives page reloads.

> **Migrating from dsh-task-notify?** That plugin is retired — its completion alert now lives here as a built-in feature (same decision rules, plus an on/off switch in Settings → General). Switch over with:
>
> ```sh
> dsh plugin --profile web remove @yangzhe1991/dsh-task-notify
> dsh plugin --profile web add @yangzhe1991/dsh-web-enhance
> ```
>
> then restart the Web GUI and refresh the tab. This plugin's other features come along for free.

## Compatibility

- **dsh ≥ 0.1.7-rc.2** — required since **0.2.0** (the release that merges in the completion alert). The watcher reads three current Client contracts: the session-list snapshot (`useSessions`: `ids` / `byId.origin`), the per-session UI status (`useSessionStatus`: agent `running` + `pendingInteraction`) and on-demand job rosters from the `jobs` client service (`watchRows` → `state.rows`). On **dsh ≤ 0.1.6** the completion alert simply stays off (the rest of the plugin is unaffected) — those versions carried job rosters inside the session-list snapshot and dialogs behind `useSessionPendingInteraction`, both removed in 0.1.7.
- **dsh ≥ 0.1.2-alpha.4** — supported since **0.1.5** for everything else. The restructured frontend provides the chat snapshot as the session-standard `useChat` hook and trajectory as `useTrajectory`; both are adapted, with the legacy paths retained for older dsh. Verified against **dsh 0.1.3-alpha.2** since **0.1.9**, against **dsh 0.1.5-rc.2** since **0.1.10** (the release that adds "Open files in the system app", verified end-to-end on a real desktop), and against **dsh 0.1.7-rc.2** since **0.1.11** — dsh 0.1.7 replaced the per-request `provenance` field with `requestConfig`/`providerMetadata` in the trajectory snapshot, which had silently hidden the cost meter; **0.1.11** reads all three shapes, so the meter keeps working from 0.1.2-alpha.4 onward. **0.2.0** (completion alert) is verified against **dsh 0.1.7-rc.2**. **0.2.1** is verified against **dsh 0.2.0-rc.1**. **0.2.2** (GLM quota badge) is verified against **dsh 0.2.1-alpha.2** — its quota channel rides on the `llm/stream` waterfall, settings volatile-field relay and `remote.settings.describe`, all official 0.2.x surfaces; **the quota badge needs dsh ≥ 0.2.0-rc.1**, everything else is unaffected (on older hosts the badge silently stays off). **0.2.5** (safety line) is verified against **dsh 0.2.1-alpha.2**.
- **Why 0.2.1 exists** — dsh 0.2.0-rc.1 checks every declared `@deepseek-ai/dsh-*` peer range against the *runtime version* and skips any plugin whose ranges do not match (`^0.1.7-rc.2` / `^0.1.3-alpha.2` exclude `0.2.0-rc.1`). No plugin code changed: **0.2.1** only widens those ranges (`^0.1.x || ^0.2.0-rc.1`). On 0.2.0-rc.1 an already-installed **0.2.0** can also be admitted without upgrading, by granting the exact-version exemption (`dsh plugin --profile <profile> allow-version @yangzhe1991/dsh-web-enhance@0.2.0 --dsh-version 0.2.0-rc.1 --accept-risk`).
  - Note for **0.2.0**: dsh 0.1.7 also renamed the icon exports of `dsh-client-ui-primitives` (`IconChevronUpOutline14` → `IconChevronUpOutlineMedium`). **0.2.0** uses the current names; older releases render an empty page on 0.1.7+.
- **Why 0.2.4 exists** — the GLM quota schema (added in **0.2.2**) calls `z.any().volatile()` while the module loads, but `Schema.prototype.volatile` only exists since schemastery **3.18.4**. The package had never declared `@deepseek-ai/schemastery`, so under registry installs the import could resolve to a stale shadowing copy (**3.18.2**), crashing the node half at import time — the host then reports the plugin as "failed to import". No code changed: **0.2.4** only declares the missing peer (`"@deepseek-ai/schemastery": "^3.18.4"`). Verified against **dsh 0.2.1-alpha.2** (build output is byte-identical to 0.2.3).
- **Why 0.2.5 exists** — the quota badge gains a **fixed-overhead safety line** (the `安全线` segment plus hover math): the line is `weekly quota − fixedDaily × days-to-reset`; crossing it means the weekly fixed overhead (always-on automation measured at ≈12.5k credits/day from the 2026-10 billing export) can no longer be covered before reset. New plugin config field `fixedDaily` (default 13000) ships inside the quota payload, which bumps to **v2** — older payloads are rejected rather than rendered without the budget. Also moved the probe's captured API fixture from `/tmp` into `probe/fixtures/`. Verified against **dsh 0.2.1-alpha.2**.
- **Why 0.2.7 exists** — adds **dropped-stream keep**: the stock agent-loop already persists every chunk received before a stream failure as an `assistant/attempt` event, but the stock frontend treats that event as invisible — the transient streaming rows are retired, nobody claims the event, and the on-screen thinking/body simply vanishes. This plugin registers its own conversation node definition for that event, expands the compact stream records into the same "safe interrupted prefix" the server uses (`BlockAssembler.interruptedBlocks` semantics — equivalence verified case-by-case plus 300 fuzz sequences), and emits an official-`assistant-step`-kind view node so the stock renderer draws it (markdown, thinking disclosures, "Stopped" badge). Verified against **dsh 0.2.1-rc.2** (assembly probe 17/17: live settlement, history replay, retry chains, empty attempts, key stability). **The feature needs dsh ≥ 0.2.0-rc.1** (the `uiConversation.events` definition-registration surface); on older hosts it silently stays off without affecting anything else.
- **dsh 0.1.0-rc.x** — still supported via the legacy snapshot paths.

## Features

- 💡 **Thinking chain default-expand** — the floating lightbulb button in the bottom-right corner (highlighted when on), sitting above the built-in "scroll to bottom" button:

  - When **on** (default), every "Think" reasoning row is expanded automatically — including rows that mount while the agent is still streaming — so you read the full reasoning text without clicking each one.
  - Toggle it off to restore the built-in collapsed one-line summaries.
  - The preference persists across reloads (localStorage). Manually collapsing an individual row is respected and will not be force-expanded.

- 💰 **Session cost meter** — on the **same line as the built-in stats line, at its far left**, e.g. `≈ ¥0.83 · 2 turns 12 steps | …`:

  - Only rendered while the session's requests go through the **official DeepSeek API** (provider route `deepseek-official`); it disappears once you switch to another API.
  - Cost = tokens × official unit prices (CNY; both models currently on sale are covered — `deepseek-flash` and `deepseek-v4-pro`; the retired names `deepseek-v4-flash` / `deepseek-v4-flash-vision-exp` are still callable and are billed at Flash rates, so they are priced the same), priced per request at its **actual time** with peak/off-peak rates (peak = Beijing time **Monday–Friday** 09:00–12:00 and 14:00–18:00, twice the off-peak price; weekends are off-peak all day), with cache-hit input at the discounted rate.
  - Models **missing from the price table** (just released upstream, or not yet synced here) are never dropped: they fall back to the **cheapest price among the known DeepSeek models**, and the hover detail says so ("not in the price table, estimated at the cheapest rate").
  - **Accumulate as it happens**: every observed request is priced at its real time and persisted immediately (localStorage, per session, last-wins so nothing double-counts) — history paging pushing old requests out of the browser window does not matter. A session that has used this plugin since its creation has an **exact total for its whole life**, regardless of how long it gets.
  - Only history that was **never loaded** (before the plugin was installed, or on another machine) has no per-request data: its remainder is estimated at the current model's off-peak rate (the cheapest-rate fallback applies here too when the model is unknown); together with any fallback-priced models this marks the total with a `≈` prefix (hover for details: model, tokens, peak/off-peak request counts, unknown models). Clicking "Load earlier" to page that history in turns the remainder exact.
  - The price table is updated in `src/client/cost.ts` whenever the official pricing page changes.

- 📂 **Open files in the system app** (on by default) — clicking a file no longer pops the right sidebar:

  - **Why**: since dsh 0.1.5, file paths in the transcript and in tool rows are hard-wired to the right sidebar's document-preview tab (literally `ctx.sidebarRight.openResource(...)` in the shipped source, with no setting to disable it), so glancing at a file means the sidebar pushes in and squeezes the conversation.
  - **What this plugin does**: it intercepts that open and hands the **absolute path** to the host's native `session.openWorkspacePath`, which opens the file in your **desktop default application** — the same thing as double-clicking it in Finder/Explorer. The conversation column stays put; the sidebar stays closed.
  - **Only file clicks are intercepted**: the sidebar's own navigation (the file tree, the guide page, tab menus) keeps working exactly as before, so you can still open a file into the official preview from the file tree; non-file resources such as image attachments are untouched.
  - **Falls back when it cannot open**: on a remote-only browser (no host desktop) or when the host refuses, the click falls back to the official sidebar preview — never a dead click.
  - **Toggle**: Settings → General → "Open files in the system app"; takes effect immediately and persists across reloads.

- 🔔 **Completion alert** (on by default) — a chime when the work is *really* over, and a tab-title alert while you are on another tab:

  - **What counts as "really over"**: a session is *busy* while its agent is running, **or** while any of its background jobs is `running`/`stopping`, **or** while a dialog is waiting for you. When it stops being busy and stays that way for ~2 seconds, the chime rings once — and only once.
    - The 2-second quiet window exists because a settling background job and the agent waking up for it are two separate streams on the client: without the window, the frame that reports the job first would look like "all done".
    - Job rosters are streamed per session and only while needed: the watcher subscribes for sessions whose agent is running **and keeps the subscription** for as long as the session still counts as busy — including the gap where the agent has already yielded and is waiting for its jobs.
    - An agent that ends its turn to wait for a background job, and a background job that finishes and wakes the agent to keep working, are both **not** the end of the work — neither rings. (That was the main source of premature alerts in the old plugin.)
  - **Dialogs ring immediately** — a tool approval, an `ask_user_question` prompt, or a plan review. Same chime as above; the tab title distinguishes the two.
  - **Tone**: ascending *do → mi* normally; descending *mi → do* when a background job settled `failed` or `killed` during that busy period.
  - **Tab-title alert**: while the page is not the active tab (`document.hidden`), the title becomes `🔔 N session(s) finished — <original title>`, or `🔔 waiting for your choice — <original title>` when a dialog is waiting. Switching back restores it.
  - **No false positives**: switching sessions, loading history, or refreshing the page never re-alerts for old turns or already-settled jobs. Background **subagent** child sessions are not announced on their own — their completion wakes the parent session, which is still the same piece of work.
  - **First-run note**: browsers block audio until you have interacted with the page. **Click or press a key anywhere once** and chimes are unlocked from then on (the tab-title alert is never affected).
  - **Toggle**: Settings → General → "Completion alert" — one switch for both the chime and the tab title; takes effect immediately and persists across reloads.

- 🪙 **GLM Coding Plan quota badge** — when a session runs on GLM, the cost-meter spot shows the subscription quota usage instead:

  - **Mutually exclusive with the ¥ meter**: the badge renders while the session's latest request runs on GLM (provider routes `zai-coding-cn` / `zai` / `zhipu`, or any model id starting with `glm`); switching back to DeepSeek restores the ¥ meter. The two gates never mix.
  - **Display**: a five-part compact badge like `5h额度18%，周额度35%，周窗口40%，安全线52%，5d15h后重置` (5h quota 18%, weekly quota 35%, week-window elapsed 40%, the fixed-overhead safety line at 52%, weekly reset in 5d15h). Comparing the weekly quota against the week window's time progress tells whether you are burning the week ahead of its clock. Hover adds each window's **time left until reset**, the safety-line math (reserve, headroom, an over-the-line warning), the plan tier and the data timestamp.
  - **Fixed-overhead safety line** — the line is `weekly quota − fixedDaily × days-until-reset`; usage above it means the reserved budget can no longer cover the fixed overhead even if everything else stops immediately. `fixedDaily` (credits/day) is a plugin config field, default **13000**; override it via the plugin row's `config: { fixedDaily: … }` in your profile's cordis patch. The value ships inside the quota payload (v2) and the line is recomputed live on every render.
  - **Account-level truth, not a local estimate**: the host calls the Zhipu quota API (`GET /api/monitor/usage/quota/limit`, Bearer auth) — the same numbers as the official usage page, including consumption by other tools such as Claude Code. The key is resolved from the credential store with automatic endpoint pairing (`ZAI_CODING_CN_API_KEY` → open.bigmodel.cn, `ZAI_API_KEY` → api.z.ai); the key never reaches the browser.
  - **Queries only while streaming**: the host fetches only while a GLM request is actively streaming (once at start, every 20s during, once more at the end); with no GLM session running there are zero external requests and zero timers. The badge itself reads the host cache once every 5 seconds — and only while a GLM session is on screen (local RPC, no external traffic).
  - **Silent without a key**: no GLM key in the credential store means no badge and no errors; diagnostics live in `document.documentElement.dataset.dshWebeQuota` (remote readiness, describe outcome, payload age).

Dropped-stream keep registers a **conversation node definition** (`ctx.uiConversation.events.register`) — not a slot, no React of its own: the definition subscribes to exactly one event type (`assistant/attempt`, table-form match, zero dispatch overhead for everything else), `start` expands the compact stream records in the event data and reassembles visible blocks under the server's own rules, and `buildViewNode` emits a node of the official `assistant-step` kind — renderer dispatch looks nodes up by kind in the `conversation.chat.node` slot, so the official AssistantNodeView renders it as-is (markdown, thinking disclosures and the "Stopped" badge are all stock drawings). The anchorSeq is the attempt event's durable seq, which sorts naturally before step/end and the turn/error row; node keys stay stable via "definition kind + event seq". Assembly rides `ctx.inject(['uiConversation'], …)` as a soft dependency (a missing service only silently disables this one feature) with state published to `dataset.dshWebeAttemptKeep`. Equivalence of the reassembly with the server's `BlockAssembler.interruptedBlocks`, and the behavior of "definition × real assembler", are verified by `probe/attempt-keep.logic.test.mjs` (313 cases incl. 300 random fuzz sequences) and `probe/attempt-keep.assembly.test.mjs` (17 cases: live settlement / history replay / retry chains / empty attempts / key stability, loading the real ui-conversation bundle and this plugin's real build artifact).

The GLM quota badge is this plugin's first host-side code, forced by a hard constraint: the quota API needs the API key, and the key lives only in the host credential store (the credentials service never returns secret values to the browser by design). The whole path rides official surfaces — the **trigger** is an `llm/stream` waterfall listener (the host already sees every model call; chunks pass through untouched via `yield* next()`, and the async generator's `finally` covers normal end, throw and cancellation — the same pattern as the official session-checkpoint-policy); the **relay** is a volatile field (`glmQuota`) on this plugin's settings entry, written only when the payload meaningfully changes (every settings write goes through a profile-patch file rewrite plus config reconciliation); the **read** is the browser half polling `remote.settings.describe()` every 5 seconds, only while a GLM session is on screen. Payload validation and badge/duration formatting live in `quota-format.ts` (dependency-free pure functions, verified offline by `node probe/glm-quota-client.test.mjs`); the trigger/dedupe/write chain is replayed with the real captured API response in `probe/glm-quota.test.mjs`.

- ⚡ **Dropped-stream keep** — when the provider cuts the stream mid-output, what already streamed no longer vanishes:

  - **Background**: GLM's sensitive-word review kills the streaming connection outright. The host (agent-loop) actually persists every chunk received before the cut as an `assistant/attempt` event — the data survives — but the stock web frontend treats that event type as invisible: the transient streaming rows are retired, no renderer claims the event, and the thinking/body on screen disappears together, leaving only an error row.
  - **What this plugin does**: registers a conversation node definition claiming `assistant/attempt` events, expands the compact stream records inside the event, reassembles the thinking/text blocks under the server's own "safe interrupted prefix" rules (tool calls are always dropped — they were never dispatched before the cut, and a call without a result would only mislead), and emits a view node of the **official `assistant-step` kind** so the stock renderer draws it: markdown body, thinking disclosures and the "Stopped" badge all behave exactly as usual.
  - **Both paths covered**: the moment of the cut (live settlement — retiring the transient rows and showing the kept row land in the same frame) and history replay after a page reload (the event was in the session log all along). In a retry chain each failed attempt keeps its own row, ordered before the error row; when an automatic retry eventually succeeds, the success renders after the failed attempts as usual.
  - **No row without content**: attempts that died before emitting a single word produce no empty row. User aborts that already had content keep using the stock `assistant/message` path (which already renders them) — no duplication.
  - **Diagnostics**: assembly state is published to `document.documentElement.dataset.dshWebeAttemptKeep` (`{"registered":true}` means the feature is live).

## Install (30 seconds)

```sh
dsh plugin --profile web add @yangzhe1991/dsh-web-enhance
```

Restart the Web GUI (`Ctrl+C` the `dsh web` process and run it again) and refresh the browser tab. (`dsh plugin` runs `pnpm add` and auto-appends the bundle to `dsh.profile.bundles`.)

For local development, install from a path instead — the `link:` spec keeps a live symlink so edits take effect after a rebuild + restart:

```sh
dsh plugin --profile web add link:/path/to/@yangzhe1991/dsh-web-enhance
```

## How it works

The thinking-chain default-expand works on the official reasoning rows (`[data-variant="think"]` with a `[data-disclosure-row][aria-expanded="false"]` row inside). Because the full reasoning text is only mounted when a row is open, the plugin clicks each collapsed row to flip the built-in component's own React state. A `MutationObserver` on `document.body` watches for newly mounted rows (streaming output, new turns) and expands them as they appear; a one-time sweep covers rows that are already rendered. Only *added* subtrees are scanned, so a row you collapse by hand is left alone.

The session cost meter registers into `conversation.composer.dock` (the same slot as the built-in stats line, sharing its row as the first element). It combines two data sources: the `trajectory` view (per-request provider/model/usage/timestamp/startSeq — the basis of the exact pricing) and the `tokenUsage` projection (whole-session token totals). The per-request provider/model has moved across dsh versions (`provenance` up to 0.1.5, `requestConfig`/`providerMetadata` from 0.1.7), so `requestRoute` in `cost.ts` reads all three shapes — a version that renames the field again shows up as an empty line, which is why the component also publishes its decision to `document.documentElement.dataset.dshWebeCost` (window request count, how many are `deepseek-official`, the latest route, the total, whether it rendered). Every request observed with usage is persisted into a per-session accumulator keyed by startSeq (localStorage, last-wins so retried usage replaces instead of double-counting); the projection's remainder beyond the accumulator (history that was never observed) is estimated at the off-peak rate and marked `≈`. The line hides whenever the most recent request is not `deepseek-official`.

The completion alert registers into `shell.overlay` (a root-scoped slot entry that renders `null`). Its decision logic is a pure state machine in `notify-monitor.ts` — no React, no DOM, injectable timers, so the timing rules can be replayed offline with a fake clock (`node probe/notify-probe.mjs` covers the interesting sequences: busy → idle, a job that keeps the session busy after the agent yields, a failure inside a busy period, a dialog appearing, and the on/off switch). React only projects the official snapshots into "per-session facts": `useSessions` (`ids` / `byId.origin`, skipping subagent children) and `useSessionStatus` (`running`, `pendingInteraction.key`), while the job rosters come from the `jobs` client service (`watchRows` per session, subscribed only while a session is running or still counted as busy). If that service is missing the alert stays off and everything else keeps working — the plugin deliberately does **not** declare `jobs` in its `inject` list, and publishes the assembly state to `document.documentElement.dataset.dshWebeNotify` (`tier`, `error`, `enabled`, session/job counts, per-session phases) so "it never rings" can be diagnosed with one line in the console instead of guesswork.

## Development

```sh
npm install
npm run build        # lib/index.js (node half) + lib/client.js (browser half)
npx tsc --noEmit     # type-check
node probe/notify-probe.mjs   # offline probe: alert state machine + bundle assembly (needs a build first)
```

Local-only notes for contributors:

- **Icon exports are version-sensitive.** dsh 0.1.7 renamed the `dsh-client-ui-primitives` icons from a size suffix (`IconChevronUpOutline14`) to a semantic one (`IconChevronUpOutlineMedium`). Importing a name the running host does not export yields `undefined`, and React turns that into `Element type is invalid` — a blank page that looks exactly like "the plugin failed to load". Check the served bundle when in doubt: the page's `assets/index-*.js` contains the module table, `grep -o 'IconChevron[A-Za-z]*'`.
- **The chat DOM is not a stable contract.** This project used to ship its own turn-navigation arrows; they were **removed in 0.2.0** because dsh 0.1.7 added an official turn rail (TurnNavigator) *and* changed the chat DOM in ways that silently break naive queries: `[data-chat-flow]` is no longer unique (every collapsed step-process group carries `data-step-process-content[data-chat-flow]` inside it), and collapsed rows are hidden via the `hidden` attribute with all-zero `getBoundingClientRect()`. If a future feature needs to walk the transcript, derive the column from a *visible* anchor row and use the official exclusion selector `:not([hidden]):not([hidden] *):not(:empty)` — and replay it offline before shipping (that is why `probe/` exists).

- **`tsconfig.json`'s `paths` must point at the same official packages the host loads** (the dsh CLI's `node_modules`), because the official packages rely on cross-package declaration merging: resolving one of them to a second copy silently drops augmentations (`GlobalStandardProps` would lose `useSessions`). The repo's own `node_modules` still carries older peer-dependency copies, so `paths` wins over them on purpose.

## Uninstall

```sh
dsh plugin --profile web remove @yangzhe1991/dsh-web-enhance
```

## License

MIT
