<div align="center">

# dsh-session-notify

[简体中文](README.md) · **English** · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · [한국어](README.ko.md)

**DSH (DeepSeek Harness) session-completion notification plugin — at the end of every turn, the completion status comes to you, instead of you staring at the screen waiting.**

[![npm version](https://img.shields.io/npm/v/@telosmaylx/dsh-session-notify)](https://www.npmjs.com/package/@telosmaylx/dsh-session-notify)
[![npm downloads](https://img.shields.io/npm/dm/@telosmaylx/dsh-session-notify)](https://www.npmjs.com/package/@telosmaylx/dsh-session-notify)
[![license](https://img.shields.io/npm/l/@telosmaylx/dsh-session-notify)](./LICENSE)
[![node](https://img.shields.io/node/v/@telosmaylx/dsh-session-notify)](https://www.npmjs.com/package/@telosmaylx/dsh-session-notify)
[![DSH](https://img.shields.io/badge/DSH-Web%20Profile-4D6BFE)](https://www.npmjs.com/package/@telosmaylx/dsh-session-notify)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/TelosmaYLX/dsh-session-notify/pulls)

At the end of every conversation turn, writes "completed / errored / blocked / hit the cap" along with duration and token usage into the session log, and pushes a browser system notification and an in-page toast (**channels are configurable separately for the unfocused and the focused window, each with an "Off" option**); **when the AI asks you a question or requests approval, it also alerts you immediately** — no need to watch the session page. Built-in 5 languages, 4 style presets (kaomoji / Felyne / Neko girl / DeepSeek-chan), a visual copy-template editor, and a custom preset library; cache hit rate and generation speed come from the official projections, consistent with the status bar.

</div>

---

## Table of Contents

- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Uninstall](#uninstall)
- [Quick Start](#quick-start)
- [Notification Behavior](#notification-behavior)
  - [Trigger Conditions](#trigger-conditions)
  - [Where Does the Push Body Come From](#where-does-the-push-body-come-from)
  - [Notification Examples](#notification-examples)
  - [Notification Permissions](#notification-permissions)
- [Configuration](#configuration)
  - [Settings Panel](#settings-panel)
  - [Template tokens and placeholders](#template-tokens-and-placeholders)
  - [Preset System](#preset-system)
  - [Host Configuration Options](#host-configuration-options)
- [How It Works](#how-it-works)
- [Project Structure](#project-structure)
- [Development & Debugging](#development--debugging)
- [FAQ](#faq)
- [Changelog](#changelog)
- [Acknowledgements](#acknowledgements)
- [Contributing](#contributing)
- [Related Links](#related-links)
- [License](#license)

---

## Features

<div align="center">

<img src="screenshot/screenshot1.png" width="220" alt="Title editor">
<img src="screenshot/ScreenShot2png.png" width="220" alt="Content editor">
<img src="screenshot/ScreenShot3.png" width="220" alt="Completion notification">
<img src="screenshot/ScreenShot4.png" width="220" alt="AI question notification">
<img src="screenshot/ScreenShot5.png" width="220" alt="Error notification">

</div>

### Three-Channel Notifications, None Missed

| Channel | Form | Description |
| --- | --- | --- |
| In-session system message | Collapsible hint line | When each turn ends, appends the end reason, duration and usage as a plugin-sourced system message into the session log, persisted with the JSONL, and still visible after restoring or replaying the session. |
| Browser system notification | Web Notification | Native popup. Each completion event uses an independent `tag` (`dsh-session-notify:<timestamp>`), so it neither replaces the previous one nor collapses into a single grouped entry; clicking the notification focuses back to the window. |
| In-page toast | Floating popup at the bottom-right | The always-shown fallback channel: still gives visible feedback when system notifications are silently suppressed by the platform, permission is denied, or the environment does not support them. At most 3 on screen at once (oldest removed when exceeded), auto-dismisses after 10 seconds, click to close. |

> [!NOTE]
> The latter two channels are **routed by the window focus state**: the settings panel has one dropdown for "Unfocused" and one for "Focused", each offering `System + in-page` / `System only` / `In-page only` / `Off`. The window counts as unfocused when `document.visibilityState === 'hidden'` or `!document.hasFocus()` — that is, when the tab is switched away, the window is minimized, or you click elsewhere, the "Unfocused" route applies.

### Full Coverage of Background Sessions

- The host maintains a "most recent notification body" session projection unit (key = `session-complete-notify`) for all sessions, including background ones whose windows aren't open, so the pushed body is consistent across sessions and does not depend on you happening to have that window open.
- The client observes the `running` flag of all sessions from the session list snapshot; a `true → false` edge triggers a push, following the same strategy as the official sidebar reminders (the first observation only records a baseline; sessions already idle are not back-filled).

### Instant Question Alerts

- When the AI calls `ask_user_question` to ask you something, the host immediately writes the "question title + body" into a dedicated projection unit (key = `session-complete-notify-question`), and the client polls it in real time and pops an alert — **even if you are looking at another page, you won't miss the question**.
- The question copy is fully customizable: the title follows the "per-reason title → global title → default title" chain, the body supports the `{question}` placeholder (injected with the AI's actual question), and the `{image}` / `{icon}` media switches work as well.

### Instant Approval Alerts

- Alerts immediately when a session requests a permission approval (`approval/asked`) and clears on `approval/decided` — you won't miss it while looking at another tab.
- Three fallback signals: the harness-native `pendingInteractions` (most accurate when the host provides it) → the host approval projection (key = `session-complete-notify-approval`, title and body rendered by the host in the current language) → the session list snapshot's `pendingInteraction === 'approval'`.
- The copy carries only the tool name and an optional reason (e.g. "The session is waiting for approval of Bash."), **never command arguments or other sensitive content**; push channel and media settings apply as well.

### Customizable Down to Every Sentence

- **5 languages**: Simplified Chinese, Traditional Chinese, English, Japanese, Korean — the notification copy, the duration and usage wording, and the settings panel UI all switch with the language (instant re-render on switch).
- **Visual template editor** (Chip editor): dynamic information renders as inline chips (placeholder code never leaks); "+ Insert Info" inserts at the cursor (can be inserted mid-text); clicking a chip removes it; each field has a live preview (information flows into the body with sample values).
- **Preset system**: a built-in "Default" baseline plus 4 one-click style presets (kaomoji / Felyne / Neko girl / DeepSeek-chan — a full set of stylized copy for the title, all 5 end reasons and the question); the current configuration can be saved as a custom preset (persisted in `localStorage`), supports auto-numbered unnamed presets (`Untitled`, `Untitled 2`…), a "From: xxx · Modified" origin indicator, and deleting presets.
- **Push title template**: when left empty, each reason uses a default title (completed = task completed / errored = task errored / … / question = AI is asking you a question); `{title}` references the session title.

### Same Source as the Official Metrics

- **Cache hit rate** comes from the official `tokenUsage` projection: cache reads / (uncached input + cache reads + cache writes).
- **Generation speed** comes from the official `sessionStats` projection: output tokens ÷ decode time.
- Both are fully consistent with the dsh-web-ui status bar, excluding queuing, preparation and tool time; when the projection is unavailable or the data is not ready, it automatically falls back to a local usage-aggregation estimate.

> [!NOTE]
> The cache hit rate and speed are only shown when inserted via the `{cache}` and `{tps}` placeholders in a custom template. With the built-in default copy, the body does NOT include duration and usage (insert the placeholders in a custom template to show them).

### Engineering Quality

- **Responds only to live events**: resume and replay do not replay old notifications; loading a session does not spam the screen.
- **Self-immune to loops**: the message type the plugin appends (`user/message`) is disjoint from its own listen target (`turn/*`).
- **Zero external dependencies**: the host plane has zero bare imports; UserMessage is constructed manually per the `dsh-llm` `createUserMessage` contract; the pure logic layer (`lib/core.js`) has zero dependencies and can be tested independently.
- **Cordis effect discipline**: the retry timer is wrapped in `ctx.effect()` and returns a `clearTimeout` disposer; the registration is automatically revoked when the fiber unloads, making HMR hot reload safe.
- **Installs and mounts in one step**: declares the official `dsh.bundle` manifest; a single `dsh plugin add` command installs it and it works, no hand-written patch needed.

---

## Requirements

| Dependency | Requirement |
| --- | --- |
| DSH (DeepSeek Harness) | Web profile deployment. The official base bundle includes `@deepseek-ai/dsh-settings` (settings namespace) and session projections by default; no extra configuration needed |
| cordis | `>=4.0.0-rc <5` (peer dependency, provided by the host) |
| Node.js | `>=22` (host side) |
| Browser | System notifications when Web Notification is supported; toast falls back when unsupported, permission denied or silently suppressed |

---

## Installation

> [!WARNING]
> A bare `npm install` only puts the package into the dependency tree; it does **not register the plugin** — this is DSH's official design (`npm install only adds the dependency; it does not register the plugin`). The only official way to auto-mount is `dsh plugin add`: it reads the `dsh.bundle` manifest inside the package (declared by this plugin since 0.1.3, pointing to the `cordis.patch.yml` at the repository root) and applies it automatically.

### Method 1: dsh plugin add (recommended)

Installing the package also automatically applies `cordis.patch.yml`, mounting the plugin into the profile assembly (host event subscription + client boot graph injection).

```bash
dsh plugin --profile web add @telosmaylx/dsh-session-notify
```

### Method 2: Install from the GitHub repository

```bash
dsh plugin add github:TelosmaYLX/dsh-session-notify
```

You can also run it inside a DSH Web GUI session:

```bash
dev_install_package github=TelosmaYLX/dsh-session-notify
```

### Method 3: Hot-mount a local directory (for development)

Replace the path with your clone directory and run it inside a DSH Web GUI session:

```bash
dev_install_package dir=/你的/克隆目录/dsh-session-notify
```

### Method 4: Manual install of the npm package

First pack it:

```bash
npm pack @telosmaylx/dsh-session-notify
```

After extracting, install from the specified directory (run inside a DSH Web GUI session):

```bash
dev_install_package dir=/解压/目录/package
```

### Method 5: Manual cordis patch (no installer required)

Append to `~/.dsh/profiles/web/cordis.patch.yml`:

```yaml
- insert:
    - id: dsh-session-notify
      name: '@telosmaylx/dsh-session-notify'
      config: {}
```

> [!IMPORTANT]
> Regardless of the method, you need to **refresh the browser page once** after installing — the client bundle is injected via the `__DSH_BOOT__` boot graph.

## Uninstall

A single command removes the plugin and its mounting (automatically removes the insert entry from `cordis.patch.yml`):

```bash
dsh plugin --profile web remove @telosmaylx/dsh-session-notify
```

> [!NOTE]
> Users who installed manually (Methods 4/5) need to also remove the corresponding insert entry from `~/.dsh/profiles/web/cordis.patch.yml`, then refresh the page.

### What Is Automatically Cleaned Up on Uninstall

The plugin implements complete lifecycle teardown (Cordis effect discipline); on uninstall/disable/HMR hot reload:

| Plane | Automatically released resources |
| --- | --- |
| host | `session/event` event subscription, settings namespace, session projection units, settings-registration retry timer (wrapped in `ctx.effect`); sets an uninstalled flag to suppress already-scheduled microtask appends |
| client | Session list subscription, poll timer for completion push bodies, `window.__dsch_notify_debug` debug hook (removed by reference to prevent closure leaks), in-page toast container DOM |

### Data Retained After Uninstall

- **Settings configuration** (language, copy templates) remains in the settings document and is automatically restored after reinstalling;
- **Custom presets** are stored in the browser `localStorage` (`dsh-scn-custom-presets`), still present after reinstalling;
- System messages already appended to historical sessions and JSONL logs are **not** rolled back (they are part of the session data, with the same semantics as the official sidebar hints).

---

## Quick Start

1. Install by any of the methods above and refresh the page.
2. Start any conversation turn and wait for it to end — a toast pops up in the bottom-right, a system notification appears in the browser, and a collapsible system hint line appears in the session log.
3. The first time a completion event is received, the browser requests notification permission (asked only once per page); after granting, subsequent completions all get system notifications.
4. Open **Settings → Plugins → Session Completion Notify**, switch languages, edit copy templates, and save presets. After saving, click "Click to Refresh" so both the host and client sides re-read, and the new configuration takes effect.

Right after installation, a collapsible hint line like this appears in the session log:

```text
会话「重构登录模块」已完成（用时 1 分 12 秒，消耗 1,240 输入 / 3,560 输出）。
```

> The default copy embeds the session-title token (`{title}`) after "Session"; when the session has no title, it automatically falls back to "Session completed".

---

## Notification Behavior

### Trigger Conditions

When each conversation turn ends (`turn/end`), the end reason is checked; hitting the whitelist triggers a notification:

| End reason | Meaning | Default |
| --- | --- | --- |
| `completed` | Session completed normally | Notify |
| `aborted` | Session aborted | Notify |
| `blocked` | Session blocked | Notify |
| `error` | Session errored (with error details, truncated when too long) | Notify |
| `max-tokens` | Reached the output token cap | Notify |
| `interrupted` | Interrupted (an orphan-turn close marker back-filled by the persistence backend after crash recovery) | No notification (configurable) |

**Subagent sessions are skipped by default** (`header.origin === 'subagent'` or `delegationDepth > 0`) — subagents are orchestrated by their parent session, so per-turn notifications are noise; the skip can be disabled in the host configuration.

**Question alerts are a separate channel and do NOT go through the whitelist above**: when the AI calls `ask_user_question` and waits for your answer (a `tool/call` event), the alert fires immediately, and it becomes invalid once the `tool/result` returns. Questions are not written to the session log — they only pop a notification.

**Approvals are another independent channel**: the alert fires immediately when a session requests a permission approval (`approval/asked`) and clears on `approval/decided`. It writes no session-log entry either — just a notification; the title and body carry only the tool name and an optional reason, never command arguments.

### Where Does the Push Body Come From

When the client observes a `running: true → false` edge in the session list, it pushes; the body is fetched with the following priority (polls for up to 6 seconds at 400ms intervals):

1. **Host projection** (key = `session-complete-notify`) — available for every session; background sessions get the full text too;
2. **The notice node in the session event window** (`kind=context` + `form=notice`) — for the session being viewed, available immediately after persistence;
3. **Fallback** — "See the in-session system message for details" + workspace info (last segment of `cwd`).

Question alerts take their body from the host projection as well (key = `session-complete-notify-question`, with the title and body already rendered by the host); on older hosts without that projection, the client assembles the title and `{question}` text itself as a fallback.

Approval alerts resolve three signal sources in order of availability: the harness-native `pendingInteractions` → the host approval projection (key = `session-complete-notify-approval`) → the session list snapshot's `pendingInteraction` field; whichever arrives first alerts, and each approval pushes only once.

### Notification Examples

All of the following are actually generated by `buildNotice` in `lib/core.js`. The default copy is uniform — "Session \"{title}\" <state>. Click to view." (wording varies per end reason; **duration and usage are not included**):

English default copy (session title in double quotes):

```text
Session "重构登录模块" completed. Click to view.   ← completed
Session "重构登录模块" aborted. Click to view.     ← aborted
Session "重构登录模块" blocked. Click to view.     ← blocked
Session "重构登录模块" hit the cap. Click to view. ← hit the cap
Session "重构登录模块" failed. Click to view.      ← errored
```

> When the session has no title (`titleValue` empty), it falls back to "Session completed. Click to view."; duration / usage / cache hit / speed only appear in custom templates via the `{duration}` `{usage}` `{cache}` `{tps}` placeholders.

Custom template (edited in the settings panel; this example uses all info slots):

```text
{title} 干完了！用时 {duration}，消耗 {usage}，缓存命中 {cache}，速度 {tps}
```

Rendered result:

```text
重构登录模块 干完了！用时 3 分 25 秒，消耗 103,600 输入 / 35,600 输出，缓存命中 96.5%，速度 92 tok/s
```

The same event in the five languages:

```text
会话「重构登录模块」已完成（用时 3 分 25 秒，消耗 1,240 输入 / 3,560 输出）。
會話「重構登入模組」已完成（用時 3 分 25 秒，消耗 1,240 輸入 / 3,560 輸出）。
Session "重构登录模块" completed (took 3m25s, used 1,240 in / 3,560 out).
セッション「重构登录模块」完了（所要 3 分 25 秒、消費 1,240 入力 / 3,560 出力）。
세션「重构登录模块」 완료（소요 3분 25초, 소모 1,240 입력 / 3,560 출력）。
```

### Notification Permissions

| Permission state | Behavior |
| --- | --- |
| `default` (undecided) | Completion events only send a toast; the "Notification Permissions" area of the settings panel provides a "Request Authorization" button (**requested within a user gesture** — Chromium ignores non-gesture automatic requests, so the plugin no longer requests automatically) |
| `granted` | Sends system notifications over the channel chosen for "Unfocused" / "Focused" (independent tags, never overwriting each other; "Off" leaves that route silent) |
| `denied` (blocked by the browser) | Toast only; the settings panel shows address-bar instructions (permission icon → site settings → notifications → allow) |
| `undefined` (non-secure context / unsupported) | Toast only; recommends switching to "In-page only" |

---

## Configuration

Most configuration is done in the **DSH Web UI → Settings → Plugins → Session Completion Notify** panel (takes effect after saving and clicking "Click to Refresh"). Only the "Trigger reason whitelist" is configured in the `config` of the host `cordis.patch.yml` (skipping subagents is controlled by a checkbox in the panel).

### Settings Panel

The panel is registered in the official "Settings → Plugins" panel (`settings.plugin.item` keyed slot, key = `session-complete-notify`), replicating the native plugin card style value by value (12px radius, expand/collapse, rotating chevron, footer status bits + discarded ghost + primary-color save button):

| Area | Content |
| --- | --- |
| Presets | Dropdown to select a built-in or custom preset; "Add" saves the current configuration as a custom preset; the current preset can be "Deleted" |
| Language | Radio selection among 5 languages; switching instantly re-renders the whole panel |
| Unfocused | Choose one of four: **Off** (fully silent in that state) / dual channel (system notification + in-page toast, default) / system notification only / in-page only — applies while the window is unfocused (tab switched away, minimized, focus lost) |
| Focused | Choose one of four: same options (dual channel by default) — applies while the window is focused. The two routes are independent and can be combined freely, e.g. "system notification when unfocused + no interruption when focused" |
| Notification media | Two sources for the large image: **per-reason upload** — insert the `{image}` token via "＋ Insert info → Image" in a template and pick a local file (shown as a chip with a thumbnail in the editor, auto-compressed to 512px wide with a 16:9 center crop matching the notification display ratio, saved per reason); **global image/icon** — two upload cards side by side in one row (**icon first**; empty = a rounded "+" tile; click to upload; **image 512×288 with a 16:9 center crop, icon 128×128 with a 1:1 square center crop**; once uploaded the card shows the thumbnail — **click it for a fullscreen preview of the full original image (aspect-ratio-preserving, uncropped)**, the "×" at the top-right removes it). Icon left empty = site default icon, or insert the `{icon}` token in a template for a **per-reason icon** (takes precedence over the global one). Effective on the system-notification channel only (the in-page toast is a text card); the "Send" test buttons apply them too |
| Title | A collapsible section (**collapsed by default**, click to expand): the **global push title** (shared by all reasons; a Chip editor — info inserted via "＋ Insert info" shows as **chip tags**, click a chip to remove; **when the notification is sent, info tokens in the title (duration / usage / error / cache / speed) are replaced with the real values, never shown as raw codes**; when left empty, each reason uses a default title — completed = task completed, errored = task errored, aborted = task aborted, blocked = task blocked, cap = task hit the output cap, question = AI is asking you a question) plus **per-reason titles** (6 inputs, one per reason, each with a "+" insert button for info tokens — including "Question", no images/icons; inserted at the caret; **takes precedence over the global title**; empty = use the global or the language default) |
| Content | A collapsible section (**collapsed by default**, click to expand). When expanded, each reason (completed, errored, aborted, blocked, output cap, question) is a **single-row layout** (reason label + Chip editor + a "+" insert button that turns into "−" while the menu is open + a **paper-plane send button**; the buttons are rectangular and vertically centered): **with an empty template (default preset) the editor shows the default text**; text + inline info chips, insert at the cursor; for `{image}`/`{icon}` chips **click the thumbnail to preview the full image, only the "×" removes it** (prevents accidental removal); other chips are removed by clicking; **after editing, clearing shows the "leave empty to use the default text" placeholder (non-selectable/non-deletable)**; the question row's default text is "AI is asking: {question}", where `{question}` is replaced by the AI's actual question when sent (the insert menu offers a "Question" token too, with the same interaction as the other tokens) |
| Skip subagent sessions | Checkbox (written into the settings document on save) |
| Notification permissions | Status shown in real time: granted (green) / not yet granted (with a "Request Authorization" button) / blocked by the browser (with address-bar instructions) / environment unsupported |
| Per-reason titles | Collapsible area (collapsed by default): one title input per end reason; empty = use the global template or the language default title |
| Save | Writes to the host settings document (`language` / `templates` / `titleTemplate` / `titleTemplates` / `pushModeBlur` / `pushModeFocus` / `skipSubagents`); shows a "Click to Refresh" link after saving |
| Reset | One-click restore of default values (**the current language is kept**, titles/templates/unfocused & focused channels restore to defaults) and saves immediately |

> [!NOTE]
> The trade-off of "Unfocused" / "Focused": `dual` (default) fires both a Windows system notification and an in-page toast; the toast is the fallback channel, guarding against system notifications being silently suppressed by the platform (focus assist, notification banner off). The two routes are independent, so you can set e.g. system notification when unfocused and complete silence when focused. **Existing configurations are unaffected** — if the settings document has no `pushModeBlur` / `pushModeFocus`, both routes fall back to the legacy single `pushMode` value. However, **Chromium-shell browsers such as QQ Browser render `Notification` as a "browser built-in in-page push popup"** (a banner at the top/corner of the page, not going through the Windows notification center) — in that case `dual` causes two in-page prompts (the browser built-in popup + the plugin toast). For such browsers, choose "In-page only" (the `Notification` API is no longer called, the browser built-in popup won't appear, and only the plugin's own small toast remains in-page); "System notification only" is ineffective in QQ Browser (it always renders as an in-page popup). The "Send" test button for each reason in the settings panel is likewise affected — the test notification prefers the "Focused" route, falls back to the "Unfocused" route when that is "Off", and falls back to `dual` when both are off, so the preview always gives feedback.

> [!NOTE]
> Whether the system notification (`Notification` API) can pop up is determined jointly by the **browser and how the site is accessed**: Edge/Chrome **auto-block notifications** for "unfamiliar" sites (a "notifications blocked" appears in the address bar) — click the permission icon on the left of the address bar → site settings → notifications → allow to restore; when accessed as a non-secure context like `http://IP`, `Notification` simply does not exist, so switch to "In-page only". The "Notification Permissions" area of the settings panel shows the current state in real time and gives the corresponding action guide (one-click authorization request). In Firefox, when the window is focused, notifications show as in-page banners and only go to the system notification center when unfocused.

> [!NOTE]
> The "Skip subagent sessions" checkbox in the panel saves a boolean into the settings document; `config.skipSubagents` in the host `cordis.patch.yml` is its startup default; if either is true, subagents are skipped.

### Template tokens and placeholders

Each end reason has its own template input; **the token is the switch** — the data is shown only when you insert the corresponding info token into the template:

| Placeholder | Meaning | Example value |
| --- | --- | --- |
| `{title}` | Session title (also usable in the push title template) | `重构登录模块` |
| `{duration}` | Duration of this turn (timed from `turn/start` to `turn/end`) | `3 分 25 秒` / `3m25s` |
| `{usage}` | Token usage (input = uncached + cache reads + cache writes) | `1,240 输入 / 3,560 输出` |
| `{error}` | Error message (shows `none` when there is no error; single-lined, truncated at 80 characters) | `connection timeout` |
| `{cache}` | Cache hit rate (per the official projection; empty when no data) | `96.5%` |
| `{tps}` | Generation speed (per the official projection; empty when no data) | `92 tok/s` |
| `{image}` | Custom notification-image switch: insert via "＋ Insert info → Image" and pick a local file (auto-compressed to 512px), independent per reason; stripped from the rendered body, never written into the session log; removing the token also clears that reason's image data | — |
| `{icon}` | Custom notification-icon switch: insert via "＋ Insert info → Icon" and pick a local file (auto-compressed to 128×128 square), independent per reason; stripped from the rendered body, never written into the session log; takes precedence over the global "Notification icon"; removing the token also clears that reason's icon data | — |
| `{question}` | **Question-row-only placeholder**: replaced with the AI's actual question text when sent; integrated with the "＋ Insert info" menu (pick the "Question" token, or just type it — both render as a chip); only available in the question channel, inserted in other reason rows it is replaced with an empty string (no literal leak) | `Should I continue generating the report?` |
| `{label}` | Deprecated — automatically stripped at render time; old templates remain compatible (the option has been removed from the insert menu) | — |

An empty template uses the built-in default copy (the uniform "Session "{title}" <state>. Click to view." phrasing; duration and usage are not included). The collapsible row's `summary` shares the same source as the body (the rendered result is truncated to 120 characters) — users who only look at the collapsible row still see the real title, duration and usage.

### Preset System

- **Built-in preset**: only "Default", serving as the baseline.
- **Custom presets**: stored in `localStorage` (key = `dsh-scn-custom-presets`):
  - After naming, "Add" saves it as a custom preset; afterwards it can be auto-synced with "Modify" or removed with "Delete";
  - **Auto-numbered unnamed presets**: saving directly from "Default / blank" automatically generates `Untitled`, `Untitled 2`, `Untitled 3`… (the number takes the current max + 1);
  - The form shows a "From: xxx · Modified" origin indicator (when loaded from a preset but the content has been changed).
- **Save syncs**: on save, if the form originates from a custom preset, that preset is updated; otherwise a new one is created or the unnamed-preset numbering continues.

### Host Configuration Options

```yaml
- insert:
    - id: dsh-session-notify
      name: '@telosmaylx/dsh-session-notify'
      config:
        reasons: [completed, aborted, blocked, error, max-tokens]
        skipSubagents: true
```

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `reasons` | `string[]` | `[completed, aborted, blocked, error, max-tokens]` | Whitelist of `turn/end` reasons that trigger notifications |
| `skipSubagents` | `boolean` | `true` | Skip subagent sessions (`origin=subagent` or `delegationDepth>0`) |

---

## How It Works

The plugin is split into a **host plane** (Node) and a **client plane** (browser), bridged by the session log (JSONL) and official session projections:

```text
┌─────────────────── 宿主平面（lib/index.js，Node）──────────────────┐
│                                                                     │
│  session/event 火线                                                 │
│   ├─ turn/start        → tracker 起表（key: sessionId:turn）        │
│   ├─ assistant/message → 累加该轮 token 用量                        │
│   ├─ tool/call         → ask_user_question？写提问投影（标题+正文） │
│   └─ turn/end          → reason.kind ∈ reasons ？                   │
│                            ├─ 子代理会话？跳过                       │
│                            ├─ 读官方投影：cache / tps / title        │
│                            ├─ 按语言+模板构建通知（summary ≤120 字） │
│                            └─ queueMicrotask 追加系统消息            │
│                                 （避开 append 重入窗口）             │
│                                                                     │
│  settings.register   → 官方「设置 → 插件」命名空间（失败退避重试）   │
│  sessionProjections  → 注册投影单元（key=session-complete-notify）  │
│                        + 提问投影（key=session-complete-notify-     │
│                          question，等待回答期间持续推送）            │
└──────────────────────────────┬──────────────────────────────────────┘
                               │ user/message (source: plugin, form: notice)
                               ▼  JSONL 持久化 + 投影推送
┌─────────────────── 客户端平面（lib/client.js，浏览器）──────────────┐
│                                                                     │
│  会话列表订阅：running true → false 边沿 → pushCompletion            │
│   ├─ 取正文：投影 → 事件窗口 notice → 降级（轮询 ≤6s）               │
│   ├─ Web Notification（独立 tag，点击聚焦）                          │
│   └─ 页内 toast（永远展示，≤3 条，10s 自动消失）                     │
│  提问投影轮询（key=session-complete-notify-question）：              │
│   有值 → 立即弹提醒（标题+正文），无值清空                            │
│                                                                     │
│  slots.inject('settings.plugin.item') → 设置卡片（预设/语言/模板）   │
└─────────────────────────────────────────────────────────────────────┘
```

### Key Design Decisions

- **No replay**: only live events are processed; resume and replay do not back-fill historical notifications.
- **No self-loop**: the plugin appends `user/message` and only listens to `turn/*`; the event types are disjoint.
- **Zero external imports**: the plugin is loaded from the repository directory by realpath, and `@deepseek-ai/*` cannot be bare-resolved — the host plane uses `createRequire` to anchor the profile's shared dependency hub (`.dsh/profiles/node_modules`) for `schemastery` (settings schema) and `zod` (projection schema); UserMessage is constructed manually per the `dsh-llm` contract (`id = crypto.randomUUID()`, deep-freeze done by the adopt-snapshot stage of `session.append`).
- **Append reentrancy avoidance**: the `session/event` observer callback runs within the publish boundary of the append fired on `turn/end` (dsh-session sets `entry.appending` before dispatch and resets it in `finally`), so a synchronous append would be rejected — therefore it is deferred to `queueMicrotask` (the microtask runs only after this synchronous stack, including the `finally` reset).
- **Effect discipline**: the settings-registration backoff retry timer is wrapped in `ctx.effect()` and returns a `clearTimeout` disposer — if the plugin is uninstalled or hot-reloaded within the retry window, the timer is torn down with the fiber and never registers against a released ctx (in very old environments without the `ctx.effect` API, it degrades to a bare timer plus a ctx-torn-down guard catch).
- **HMR safe**: the `core.js` import carries `?v=1` cache busting (HMR reload is keyed by URL); if settings registration hits a hot-reload race (duplicate), it automatically backs off and retries (up to 8 times, interval `400ms × attempts`).
- **Dual-track projection registration**: prefers `ctx.root.get('sessionProjections')` (the instance closest to the host root), falling back to the injection instance when unavailable; if registered only into the injection instance, the client may not read the projection unit and the push body takes the fallback path — best-effort, does not affect in-session system messages.

---

## Project Structure

```text
dsh-session-notify/
├── lib/
│   ├── index.js      # 宿主平面（Node）：session/event 订阅 → 系统消息落盘；
│   │                 #   settings 命名空间注册（schemastery schema，退避重试）；
│   │                 #   sessionProjections 投影单元（后台会话推送正文）
│   ├── core.js       # 纯逻辑层（零依赖，可独立测试）：轮次计时与用量聚合、
│   │                 #   5 语言文案表、时长/用量/缓存/速度格式化、
│   │                 #   模板渲染（{title}{duration}{usage}{error}{cache}{tps}）、
│   │                 #   提问正文构建（buildQuestionBody，{question} + 媒体剥除）
│   └── client.js     # 浏览器平面：完成推送（系统通知 + toast）、
│                     #   设置卡片（Chip 模板编辑器 + 预设系统 + 实时预览）
├── scripts/
│   ├── build.sh                # 零构建：仅 node --check 语法校验
│   ├── verify-notice.mjs       # 校验会话日志落盘证据（zstd 多帧逐帧解压）
│   ├── probe-client.mjs        # 探针：客户端装配
│   ├── probe-client-e2e.mjs    # 探针：客户端端到端
│   ├── probe-card-render.mjs   # 探针：设置卡片渲染
│   ├── probe-settings-card.mjs # 探针：设置面板卡片
│   ├── probe-settings-check.mjs# 探针：设置面板检查
│   └── probe-diag-settings.mjs # 探针：settings 诊断
├── cordis.patch.yml  # dsh.bundle manifest —— dsh plugin add 自动挂载的凭证
├── package.json      # dsh.bundle（patch）+ dsh.client（web 注入）双 manifest；
│                     #   exports: "." / "./client" / "./core"
├── LICENSE           # MIT
└── README.md         # 本文档
```

---

## Development & Debugging

Syntax check (zero-build, the same check as `prepublishOnly`):

```bash
npm run build
```

Publishing (runs the `prepublishOnly` syntax check automatically before publishing):

```bash
npm publish --registry=https://registry.npmjs.org --access public
```

Offline verification: extracts all plugin-source events and the `turn/end` tail sequence from a session log (when no path is given, automatically selects the newest session under `~/.dsh/sessions`):

```bash
node scripts/verify-notice.mjs <session.jsonl.zstd>
```

### Debugging Entry Points

| Entry point | Content |
| --- | --- |
| `~/.dsh/session-complete-notify.log` | Host diagnostic log: settings registration, retries and failures, projection registration, append-failure stacks |
| Browser console `[dsh-session-notify-client]` | Client log: permission state, notification display, settings save |
| `window.__dsch_notify_debug.readNotice(id)` | Manually read the latest notification body of a given session |
| `window.__dsch_notify_debug.snapshotDebug(id)` | Session tail node types + notice count + most recent body (first 200 chars) |

---

## FAQ

<details>
<summary><b>Why isn't it auto-mounted after npm install?</b></summary>

This is DSH's official design: `npm install` only puts the package into the dependency tree, it does not register the plugin. The only way to auto-mount is `dsh plugin add` — it reads the `dsh.bundle` manifest inside the package (declared by this plugin since 0.1.3) and automatically applies `cordis.patch.yml`. See [Installation](#installation).

</details>

<details>
<summary><b>Will I also be alerted when the AI asks me a question?</b></summary>

Yes. When the AI calls `ask_user_question` and waits for your answer, the host immediately writes the "question title + body" into a dedicated projection (key = `session-complete-notify-question`), and the client pops an alert as soon as it polls the value — even if you are looking at another page, you won't miss it. The question copy is as customizable as the completion notifications: the "Title / Content" sections of the settings panel each have a "Question" row, the body supports the `{question}` placeholder (injected with the AI's actual question), and the `{image}` / `{icon}` media switches work too. Once you answer (`tool/result`), the alert is invalidated and does not linger.

</details>

<details>
<summary><b>Why is "interrupted" not notified?</b></summary>

`interrupted` is an orphan-turn close marker back-filled by the persistence backend after crash recovery; from the user's perspective it is not a "completion" (otherwise restoring a session would flood the screen with false reports). If you really need it, you can add it to `reasons` in the host configuration.

</details>

<details>
<summary><b>Do background sessions (with no window open) also get pushed?</b></summary>

Yes. The client observes the `running` edges of all sessions from the session list snapshot; the body prefers the host projection — the host maintains a projection unit for all sessions (including background ones), so the pushed body is consistent across sessions. When the projection is unavailable, it falls back to the event window or workspace info.

</details>

<details>
<summary><b>Why does it ask me to refresh the page after saving settings?</b></summary>

The host reads the settings once when registering the namespace, and the client bundle is assembled at page load. After saving, click "Click to Refresh" so both sides re-read, and the new language and templates take effect.

</details>

<details>
<summary><b>Where do the cache hit rate and speed data come from? Why are they sometimes empty?</b></summary>

They come from the official `sessionProjections` (`tokenUsage`, `sessionStats`), consistent with the dsh-web-ui status bar. When the host fails to read the projection snapshot or the data is not ready, it falls back to a local usage-aggregation estimate; if there is still no data, the field is left empty (the token shows nothing even if inserted). Additionally, these two appear only when inserted via the `{cache}` and `{tps}` placeholders in a custom template; the default copy does not include them.

</details>

<details>
<summary><b>What if the error message in the notification body is too long or has line breaks?</b></summary>

Both the summary line (collapsible row) and error details are single-lined and truncated: summary 120 chars, template `{error}` 80 chars, default-copy error details 40 chars; overlong text ends with an ellipsis.

</details>

<details>
<summary><b>Can I customize the icon or sound of system notifications?</b></summary>

The **icon is customizable**: in the settings panel's "Notification image" section you can upload a hero image and an icon (global), or insert the `{icon}` tag in a per-reason template to assign a per-reason icon (takes precedence over the global one). **Sound** is not customizable (the system/browser default is used); the toast is a fixed dark card. For other needs, feel free to open an Issue or PR.

</details>

<details>
<summary><b>Why can't Edge push system notifications? Why does QQ Browser only show an in-page banner (built-in push popup)?</b></summary>

Both are browser behaviors; the plugin cannot force anything:

- **Edge / Chrome**: **auto-block notifications** for "unfamiliar" sites (a "notifications blocked" appears in the address bar). Click the permission icon on the left of the address bar → site settings → notifications → allow to restore, after which the Windows notification center works normally. You can also turn off "auto-block" in the browser's notification settings.
- **Chromium shells like QQ Browser**: always render `Notification` as a **browser built-in in-page push popup** (a banner at the top/corner of the page, not going through the Windows notification center), and there is no system-notification option. The "Unfocused" and "Focused" dropdowns behave identically here:
  - `System + in-page` → browser built-in popup + plugin toast, two in-page prompts;
  - `System only` → ineffective (QQ Browser always renders it as an in-page popup);
  - `In-page only` → the browser built-in popup doesn't appear; only the plugin's small toast remains in-page (recommended);
  - `Off` → that state is fully silent.
  The "Send" test button for each reason in the settings panel is likewise rendered by this rule.
- **Firefox**: when the window is focused, notifications show as in-page banners; they go to the system notification center only when unfocused/minimized; permission must be manually allowed in the address bar.
- Also note: when accessed via `http://IP` (non-secure context), `Notification` does not exist, so no browser can pop a system notification.

The "Notification Permissions" area of the settings panel shows the current state and the corresponding action guide in real time.

</details>

---

## Changelog

| Version | Date | Changes |
| --- | --- | --- |
| **0.1.21** | 2026-09-14 | **Push channels split by unfocused / focused state** ([PR #3](https://github.com/TelosmaYLX/dsh-session-notify/pull/3) by [@YiHui-Liu](https://github.com/YiHui-Liu)): adds two independent dropdowns, "Unfocused" and "Focused", each offering `Off` / `System + in-page` / `System only` / `In-page only`; removes the old single "Push mode" setting — when the new keys are unset both routes fall back to the legacy `pushMode` value (existing configurations behave unchanged); setting a route to "Off" makes that state fully silent (questions and approvals skip deduplication, so the same event still alerts once the page moves to the other state; completion is an edge event, so a silent state does not replay it); the "Send" test notification prefers the "Focused" route, falls back to the "Unfocused" route when that is "Off" |
| **0.1.20** | 2026-09-09 | **Instant approval alerts + historical-session load fix**: adds permission-approval alerts ([PR #2](https://github.com/TelosmaYLX/dsh-session-notify/pull/2) by [@YiHui-Liu](https://github.com/YiHui-Liu) — an `approval/asked` projection plus a three-signal client fallback); fixes the 0.1.19 regression where opening a historical session failed with `undefined.parse` — projection registration now carries both contract generations (`schema`/`view` and `stateSchema`/`wire`); the client's `uiSession` moves out of `inject` into an optional `ctx.get` lookup so a missing service can no longer park the whole client half (notifications and settings panel). |
| **0.1.19** | 2026-09-07 | **Fix question popup (broken host projection link)**: projection unit registration migrated to the `stateSchema` + `wire: { viewSchema, view }` contract — the old shape (top-level `schema`/`view`) is a host-only unit under the new host (dsh-session-projection), so its values never reach the client and both the completion and question projections stop working; when `tool/call` carries an empty `callId` (some OpenAI-compatible proxy routes), the question id falls back to `turn:step` and `tool/result` now clears via the same turn/step match; also fixes a latent crash in the projection checkpoint restore path when `stateSchema` is missing |
| **0.1.18** | 2026-09-01 | **Fix question alert not firing**: on some dsh versions (0.1.2) the host projection never reaches the client, so asking a question shows no popup; the client question push now adds a harness-native "question pending" fallback trigger so it still alerts when the projection is absent; completion push / settings panel behavior unchanged |
| **0.1.17** | 2026-08-30 | **Instant question alerts (customizable)**: alerts immediately when the AI asks; question copy supports the `{question}` placeholder and media switches; all 4 presets gain question copy in 5 languages; fallback for older hosts |
| **0.1.16** | 2026-08-30 | **Interaction fix**: holding Backspace no longer deletes chips prematurely (chips are deleted only when no text sits between the caret and the chip) |
| **0.1.15** | 2026-08-30 | **Interaction polish**: Content fold is expanded by default; after deleting a chip the caret lands directly on real content for continuous deletion |
| **0.1.14** | 2026-08-30 | **Code-review fixes**: preset-delete confirmation, preset restores to saved config, media × clears the preview, per-reason images participate in preset matching, duplicate-name warning, reset enabled only when dirty, debug log auto-truncation |
| **0.1.13** | 2026-08-29 | Added 4 one-click style presets (kaomoji / Felyne / Neko girl / DeepSeek-chan), all in 5 languages |
| **0.1.12** | 2026-08-29 | Release cleanup |
| **0.1.11** | 2026-08-29 | **Custom notification media**: insert `{image}`/`{icon}` tokens and upload images/icons (auto-cropped); title supports info placeholders; "Body templates × 5" becomes a fold section; layout & interaction polish |
| **0.1.10** | 2026-08-29 | Native input for the push title; added multilingual READMEs (English / 繁體 / 日本語 / 한국어) |
| **0.1.9** | 2026-08-29 | Per-reason push titles; projection upgraded to an object; reset keeps the language; per-reason "Send" test buttons |
| **0.1.8** | 2026-08-29 | Default title "Task completed"; per-reason default copy; added a Reset button |
| **0.1.7** | 2026-08-29 | Fixed the settings-card crash (notification permission row scope issue) |
| **0.1.6** | 2026-08-29 | Notification permission status area; permission requested within a user gesture |
| **0.1.5** | 2026-08-29 | Push channel setting (dual / system-only / in-page-only) — fixes double alerts on QQ Browser |
| **0.1.4** | 2026-08-28 | Full uninstall support (dispose lifecycle cleanup) |
| **0.1.3** | 2026-08-28 | Declared dsh.bundle manifest; settings retry timer wrapped in ctx.effect() |
| 0.1.2 | 2026-08-27 | Renamed to `@telosmaylx` scope |
| 0.1.1 | 2026-08-27 | Documented GitHub & npm install methods |
| 0.1.0 | 2026-08-26 | Initial release: in-session system message + browser push + official settings panel |

---

## Acknowledgements

Thanks to [@YiHui-Liu](https://github.com/YiHui-Liu) for two contributions: [PR #2](https://github.com/TelosmaYLX/dsh-session-notify/pull/2) — instant permission-approval alerts (the `approval/asked` projection plus the client's three-signal fallback); and [PR #3](https://github.com/TelosmaYLX/dsh-session-notify/pull/3) — push channels split by unfocused / focused state, each with an "Off" option.

---

## Contributing

Issues and PRs are welcome:

1. Fork the repository and create a new branch (`feat/xxx`)
2. After changes, run `npm run build` for the syntax check
3. Submit a PR, describing the motivation and how you verified it

Please follow the [Cordis development tutorial](https://deepseek-harness.github.io/deepseek-harness/develop/cordis-tutorial) discipline before submitting:

- Resources outside Cordis (timers, subscriptions, watchers) must be wrapped in `ctx.effect()` and return a disposer;
- Configuration items must have an explicit `id` to prevent edit drift;
- A plugin must declare the `dsh.bundle` manifest to be recognized and installed by `dsh plugin add`.

---

## Related Links

- [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) — A curated list of DSH plugins (submission rule: `dsh.bundle` is the only credential for installation)
- [Cordis development tutorial](https://deepseek-harness.github.io/deepseek-harness/develop/cordis-tutorial) — The full plugin-development process (chapters 01–07)
- [npm package homepage](https://www.npmjs.com/package/@telosmaylx/dsh-session-notify)
- [GitHub repository](https://github.com/TelosmaYLX/dsh-session-notify)

---

## License

[MIT](./LICENSE) © dsh-session-notify contributors
