<p align="center">
  <strong>Автоматические сессионные ворота в часы пик: режим выходных + автопауза в пик + двухмерное определение официальных источников + заморозка на уровне сессии + автоматический повтор на стороне бэкенда</strong>
</p>
<img width="832" height="182" alt="00c4b89a-b026-4bf1-a358-a068e80d2da7" src="https://github.com/user-attachments/assets/31a8836f-0fe0-4043-948a-f0865bb1b3bb" />

<p align="center">
  <a href="README.en.md">English</a> · <a href="README.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.fr.md">Français</a> · <a href="README.de.md">Deutsch</a> · <a href="README.it.md">Italiano</a> · <strong>Русский</strong> · <a href="README.es.md">Español</a>
</p>
<p align="center">
  <a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-263146?style=flat-square"></a>
  <img src="https://camo.githubusercontent.com/2c11fb2e0e14bb9985c5acbe61123a7441c5ee63aa27fa6e04e2a707ebfd6022/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6473682d2d706c7567696e2d72656164792d3437384342463f6c6f676f3d646565707365656b266c6f676f436f6c6f723d7768697465" alt="dsh-plugin" data-canonical-src="https://img.shields.io/badge/dsh--plugin-ready-478CBF?logo=deepseek&amp;logoColor=white" style="max-width: 100%;">
  <img alt="Public beta" src="https://img.shields.io/badge/status-public%20beta-7da1de?style=flat-square">
</p>

# dsh-session-guard

- [English README](./README.en.md)
- [中文 README](./README.md)
- [日本語 README](./README.ja.md)
- [한국어 README](./README.ko.md)
- [README en français](./README.fr.md)
- [README auf Deutsch](./README.de.md)
- [README in italiano](./README.it.md)
- [README на русском](./README.ru.md)
- [README en español](./README.es.md)
- [Installation guide](./INSTALL.md)
- [中文安装指南](./INSTALL.zh.md)
- [日本語インストールガイド](./INSTALL.ja.md)
- [한국어 설치 안내](./INSTALL.ko.md)
- [Guide d'installation en français](./INSTALL.fr.md)
- [Installationsanleitung auf Deutsch](./INSTALL.de.md)
- [Guida all'installazione in italiano](./INSTALL.it.md)
- [Руководство по установке на русском](./INSTALL.ru.md)
- [Guía de instalación en español](./INSTALL.es.md)
- [Changelog](./CHANGELOG.md)
- [日本語 changelog](./CHANGELOG.ja.md)
- [한국어 changelog](./CHANGELOG.ko.md)
- [Changelog en français](./CHANGELOG.fr.md)
- [Changelog auf Deutsch](./CHANGELOG.de.md)
- [Changelog in italiano](./CHANGELOG.it.md)
- [Changelog на русском](./CHANGELOG.ru.md)
- [Changelog en español](./CHANGELOG.es.md)

> **Замечание о совместимости:** в v0.1.1 уже входят словари японского (`ja`) и корейского (`ko`), но текущие официальные сборки DSH предоставляют через `LocaleRuntime` только `zh` и `en`. В оригинальном DSH выбор `ja` или `ko` завершается ошибкой `locale "<id>" is not registered`. Придётся дождаться, пока официальный DSH добавит соответствующие идентификаторы локалей. Опытные пользователи могут поддерживать форк DSH, чтобы расширить его.

> **▼ Совместимость версий DSH**
>
> | Версия DSH | Загрузка | Регистрация настроек | События сессии / ворота | Клиентская половина |
> | --- | --- | --- | --- | --- |
> | 0.1.0-rc.7 ~ 0.1.1-rc.x | ➖ вне этой линии (исторические версии до `legacy/0.1.2`) | `ctx.settings.register(ns, schema, { base })` | ✅ форма совпадает | ✅ без импорта платформенных значений |
> | 0.1.2-alpha.2+ / 0.1.2-rc.1 | ➖ вне этой линии → используйте `legacy/0.1.2` (npm `@dsh-0.1.2`) | `register` сохранён (добавлен `installSection`) | ✅ форма совпадает | ✅ без импорта платформенных значений |
> | **0.2.0-rc.1+** | ✅ (**эта линия**, dist-tag `dsh-0.2.0`, npm `4.0.0`) | декларативно: поля `.volatile()` в Config хост проецирует в формы, `register` удалён | ✅ события читаются по двойному пути `snapshotEvents()` | ✅ карточка настроек клиента переведена на `configForms` |
> | 0.1.7-rc.1+ | ✅ (ветка `compat/0.1.7`, dist-tag `dsh-0.1.7`) | декларативно: поля `.volatile()` в Config хост проецирует в формы, `register` удалён | ✅ события читаются по двойному пути `snapshotEvents()` | ✅ карточка настроек клиента переведена на `configForms` |
> | 0.1.5-rc.2 | ✅ (ветка `compat/0.1.5`, dist-tag `dsh-0.1.5`) | `register` на месте (строковые пространства имён) | ✅ события читаются по двойному пути `snapshotEvents()` | ✅ |
>
> **Идентификация этой линии**: ветка `compat/0.2.0`, номер версии npm **`4.0.0`** (semver), dist-tag **`dsh-0.2.0`**.
> `engines.dsh` в `package.json` и `dsh.plugin.json`, а также четыре peer-зависимости `@deepseek-ai/dsh-client-*`
> единообразно приведены к `0.1.0-rc.2 || 0.1.0-rc.3 || 0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8 || 0.1.1-rc.1 || 0.1.1-rc.2 || 0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5`; нижняя граница peer-зависимости `@deepseek-ai/cordis` выровнена по `^4.0.4` хостовой линии.
> Раньше README использовало «2.x / 3.x» как **кодовые имена линий** — это была манера изложения, а **не номера версий, которые можно
> получить из реестра**, — ориентируйтесь только на npm-версии `0.3.1` (линия 0.1.2), `3.0.0`/`3.0.1` (линия 0.1.5),
> `3.2.4` (линия 0.1.7) и `4.0.0` (линия 0.2.0).
>
> **Адаптация 0.2.0 (ветка compat/0.2.0, npm 4.0.0)**:
> API плагинов, которую использует этот плагин (manifest/settings/HMR/slot/сессии V4), в 0.2.0-rc.1 полностью совместима с 0.1.7;
> адаптация сводится к смене поколения метаданных (peer/engines/dist-tag/версия 4.0.0). Попутно с базой перенесены инженерные исправления:
> ① `src/client/family-section.tsx` добавлен под контроль версий (раньше существовал только в локальном рабочем дереве, чистый checkout не мог собрать проект);
> ② из конфигурации eslint убрано ядрёное `no-unused-vars`, ошибочно помечавшее параметры в сигнатурах типов TS, — lint-базовая линия снова полностью зелёная;
> ③ статические шлюзы подписей путей записи v4 (`tests/source-kind.test.mjs`) переносятся в эту линию вместе с базой;
> ④ peer-зависимость `@deepseek-ai/cordis` выровнена по `^4.0.4` (пакеты UI хоста 0.2.0-rc.1 объявляют `~4.0.4`; прежний `^4.0.1` не конфликтовал, но его нижняя граница была слишком стара).
>
> **Где оказываются другие линии**: для хостов DSH `0.1.2-rc.x` используйте ветку **`legacy/0.1.2`** (npm dist-tag
> `dsh-0.1.2`, версия `0.3.1`). **`main` заморожен на `0.2.0-beta.1` и не является веткой релизов линии 0.1.2.**
> Для хостов DSH `0.1.0-rc.7` ~ `0.1.1-rc.x` используйте исторические версии ≤ `0.1.2`.
> Канонические определения источников полей:
> `mine-dsh-plugins/improve-dsh-plugins/DSH-PLUGIN-VERSION-DISTRIBUTION-STRATEGY.md` §2.2.
>
> **Адаптация 0.1.5 (ветка compat/0.1.5, npm 3.0.0)**:
> ① двойной путь для `agent.followup` — 0.1.5 превращает Inbox в проекцию agent-loop только для чтения; если `followup`
> больше не существует, происходит откат к `agent.send`, а если недоступно ни то ни другое — понижение до warn без исключений (страховка возобновления);
> ② возобновляющие сообщения добавляют в `source` поле `form: 'instructions'` (контракт ContextFormed из 0.1.5, старые версии его игнорируют);
> ③ `webServer.register` обёрнут в try/catch: неудачная регистрация лишь пишется в журнал и не рушит загрузку плагинов хоста;
> ④ 0.1.5 удаляет доступ к массиву `session.events`; чтение событий идёт по основному пути `snapshotEvents()` +
> с откатом к старому массиву (затрагивает только два вспомогательных пути `findToolOutcome` / `lastUserPrompt`, сопоставление типов событий не меняется).
> Проверено, что контракт `WebRoute` 0.1.5 (exact/prefix + SSE) не изменился: клиентскому
> `fetch('/session-guard/...')` префикс `/api` не нужен.
> Приведённая таблица совместимости охватывает два поколения API (0.1.1 / 0.1.2); эта линия претендует только на строку 0.1.5.
> `session/event`, `agent.cancel`, `goals.pause`,
> `agent.followup`, `commands.register`, `timer.interval`, `webServer.register`,
> `agent/request`, `llm.listConfigurableProviders`, `settings.register/get` имеют одинаковые подписи
> между `dsh-v0.1.1-rc.2` и `dsh-v0.1.2-rc.1` (эта линия проверена вплоть до `dsh-v0.1.5-rc.2`);
> единственное место, требующее двойного чтения, — форма id вызова, записываемого `tool/result` (приоритет у `content[].toolCallId`,
> откат к `source.callId`), вынесена в `src/tool-call-id.js` с модульными тестами — в журналах воспроизведения обеих версий могут встречаться обе формы.
> С 0.1.5 доступ к массиву `session.events` удалён; эта линия читает через `snapshotEvents()` (с сохранением отката к старому массиву),
> затрагивая только два вспомогательных пути `findToolOutcome` / `lastUserPrompt`.
> Событие `model/selection` есть **только с 0.1.2**, служит лишь для ускорения смены модели и обязательно требует проверки доступности функции; поверхность настроек использует только
> пересечение `register` + `get` (никогда `installSection` / удалённый `installSettingsSection`).
> Скрипт-страж дрейфа: `tools/check-api-drift.ps1` (эта линия по умолчанию проверяет наличие нужных интерфейсов относительно `dsh-v0.1.5-rc.2`).

> Автоматически приостанавливает запущенные сессии в часы пик и автоматически возобновляет их вне пиковых часов и в выходные; вместе с кнопкой заморозки input-traffic даёт блокировку **на уровне сессии**; **автоматический повтор** на стороне бэкенда уступает дорогу во время заморозки/действия ворот. В основе — **собственные сессионные ворота** (`agent.cancel keepInbox + goals.pause + session/event безопасная граница + возобновление через followup`), зависимость от dsh-task-control устранена.

Плагин cordis, собираемый командой `dsh plugin` и патчем бандла — без правки исходников dsh и без PR.

> 💡 **Почему стоит установить**: с 2026-08-17 DeepSeek перешёл на **тарификацию по пиковой и непиковой зоне** — в пиковые часы (пекинское время 9:00-12:00, 14:00-18:00) цена единицы **в 2 раза** выше, чем в непиковые (включая полдень, ночь, выходные и праздники). Плагин автоматически останавливает запущенные сессии в пик и автоматически возобновляет их после пика: длинные прогоны со сдвигом по времени экономят до **50 %**; ручная заморозка (через кнопку input-traffic) даёт ещё более точное управление остановкой по каждой сессии.

## Обзор возможностей

- **Режим выходных**: распознаёт выходные (через `Intl.DateTimeFormat` с настроенным часовым поясом, без классической ошибки в 8 часов на границе UTC+8 из-за голого `getUTCDay()` для пекинского времени) → в выходные пиковая зона игнорируется, можно работать без ограничений.
- **Автопауза в пик (глобально)**: при входе в пик (и если не выходные) автоматически приостанавливаются все корневые сессии в состоянии `running`; после пика все автоматически возобновляются — **глобальный переключатель, вручную ничего делать не нужно**.
- **Двухмерное определение официальных источников (providerGuard)**: в пиковые часы блокируется **только если целью запроса является официальный источник DeepSeek**; с локальными/сторонними провайдерами (например `local-35b`) работа идёт как обычно, пиковые ворота её не трогают. Порядок определения = явный список id → эндпоинт `baseURL` → эндпоинт по умолчанию из каталога → встроенный id.
- **Страховка на уровне запроса + отложенная очередь**: сессии, запущенные уже после входа в пик, или переключённые на ходу на официальный источник, перехватываются стражем уровня запроса `agent/request` (по умолчанию `hold`: запрос приостанавливается без ошибки и автоматически выпускается после пика).
- **Заморозка / возобновление на уровне сессии**: избыточный порт `sessionGuard` + `POST /session-guard/rpc`, подключается по каждой сессии через кнопку заморозки input-traffic; есть и ручные команды `/pause /resume /cancel`.
- **Автоматический повтор на стороне бэкенда (D9)**: переходящие сбои turn/end (error/429/max-tokens) автоматически возобновляются с адаптивной задержкой; постоянные сбои останавливают работу; **уступает дорогу во время заморозки/действия ворот**, никогда не обходит сессионные ворота.
- **fail-open**: собственные сессионные ворота недоступны, session-guard не установлен, сервис настроек отсутствует — во всех случаях тихая деградация, никогда не падает из-за зависимости.

## Просмотр интерфейса

Скриншот реальной работы (Windows, dsh web) — активный режим выходных:

<figure>
  <img style="max-width:100%" alt="Панель управления состоянием в области ввода: активная кнопка «Выходные» подсвечена (при включённом режиме выходных сессии работают свободно, без учёта пика), рядом кнопка «Заморозить сессию» (вместе с input-traffic), режим рассуждений DeepSeek-V4-Flash и элементы отправки, внизу полосы состояния: раунды/шаги, время LLM, доля попаданий в кэш и т. д." src="assets/高峰低峰周末提醒-周末状态.png" />
  <figcaption>Режим выходных активен: бейдж «выходные» в области ввода подсвечен, рядом «Заморозить сессию»; в выходные пиковая зона игнорируется, сессии работают свободно.</figcaption>
</figure>

## Установка

```bash
# Хосты DSH 0.1.5-rc.x (эта линия, dist-tag dsh-0.1.5)
dsh plugin --profile web add dsh-session-guard@dsh-0.1.5

# или напрямую из ветки git
dsh plugin --profile web add github:drscrewdriver/dsh-session-guard#compat/0.1.5

# Хосты DSH 0.1.2-rc.x должны использовать линию 0.1.2
dsh plugin --profile web add dsh-session-guard@dsh-0.1.2
```

`compat/0.1.5` — выделенная линия для DSH `0.1.5-rc.x`, номер версии npm **`3.0.0`**; для хостов DSH `0.1.2-rc.x` используйте
ветку **`legacy/0.1.2`** (npm dist-tag `dsh-0.1.2`, версия `0.3.1`). **`main` заморожен на `0.2.0-beta.1`,
это не ветка релизов линии 0.1.2.**

> ⚠️ Не полагайтесь на «голое» имя пакета `dsh-session-guard`: тег `latest` в npm не может обслуживать сразу две взаимоисключающие
> линии версий (их диапазоны `engines.dsh` взаимоисключающи по правилам semver для prerelease) — dist-tag нужно указывать явно.

После установки перезапустите dsh web и обновите страницу.

## Настройки (Настройки → Плагины → session-guard, простые переключатели)

| Переключатель | По умолчанию | Описание |
|---|---|---|
| `enabled` | on | **Автопауза/заморозка сессий в пик**: автоматически приостанавливает запущенные сессии в пиковые часы |
| `stepLevelPause` | on | **Ворота на уровне шага**: в пик ворота закрываются **до** запроса к модели следующего шага (раньше и экономнее, чем пауза на уровне раунда); off — откат к паузе на уровне раунда |
| `providerGuard` | on | **Двухмерное определение официальных источников**: в пик блокируются только официальные источники DeepSeek, локальные/сторонние провайдеры работают как обычно |
| `guardSubagents` | on | **Включая запросы субагентов**: запросы субагентов тоже тарифицируются, по умолчанию блокируются вместе с остальными |
| `offPeakAutoResume` | on | **Автовозобновление в непик**: приостановленные сессии автоматически возобновляются в непиковые часы; off — после пика автоматического возобновления нет (нужна ручная команда) |
| `weekendMode` | on | **Режим выходных**: распознаёт выходные → в выходные автопаузы нет (в выходные нет пикового времени, можно работать свободно) |
| `deferredResume` | on | **Автовозобновление после пика**: off — отложенные запросы/сессии не возобновляются сами, нужен ручной `/resume` |
| `queueFallback` | on | Откат к очереди ожидания под блокировкой, когда собственные сессионные ворота недоступны (fail-open) |
| `retryEnabled` | off | **Автоповтор (бэкенд)**: автоматическое возобновление после переходящих сбоев (по умолчанию off, консервативно) |

Дополнительные параметры:

- `timezone` (по умолчанию Asia/Shanghai) — часовой пояс для **определения выходных** и отображения бейджа; **на определение пика не влияет** (пик всегда по пекинскому времени);
- `peakWindows` (по умолчанию 09:00–12:00 / 14:00–18:00) — окна пика/непика по пекинскому времени (UTC+8), совпадают с официальной тарификацией DeepSeek;
- `pauseMode` (`safe`/`force`), `pauseReason` (`wait`/`stop`) — способ выполнения паузы;
- `stepGateTimeoutMs` (по умолчанию 300000) — таймаут удержания ворот шага; по истечении ворота открываются с **повышением до паузы на уровне раунда** (защита от дедлока, без «одного шага каждые 5 минут» и утечки токенов);
- Определение официальных источников: `officialProviders` (дополнительные id официальных провайдеров, через запятую, наивысший приоритет), `officialBaseURLs` (список хостов официальных эндпоинтов, по умолчанию `api.deepseek.com`);
- Отложенная очередь: `deferredMode` (`hold` удержание / `error` ошибка и отложение), `deferredResumeText` (текст возобновления после пика), `deferredMaxHoldMs` (предел удержания, по умолчанию 6 часов, по истечении переводится в error);
- Параметры повтора: `retryText`, `retryGraceMs`, `retryCooldownMs`, `retryBackoffFactor`, `retryBackoffMaxMs`, `retryMaxConsecutive`.

## Поведение

### Автоматические пиковые ворота (глобально)

- **Вход в пик** (и не выходные): при включённом `stepLevelPause` текущий раунд **больше не обрывается сразу** — сессия естественно доходит до следующей границы `agent/pre-step`, где закрываются ворота шага (см. следующий раздел); при выключенном для всех корневых сессий `running` вызывается `gate.stopNextTurn` (настоящая пауза через собственные сессионные ворота, либо в зависимости от `queueFallback` откат к очереди ожидания под блокировкой);
- **Выход из пика / выходные**: сначала `releaseAll` освобождает удерживаемые шаги (раунд продолжается на месте), затем `gate.resume` для **всех** сессий — управляется переключателем `offPeakAutoResume`, при выключенном после пика автоматического возобновления нет;
- **Часовой пояс пика**: всегда пекинское время (`Asia/Shanghai`), совпадает с базой официальной тарификации DeepSeek и не зависит от настройки `timezone`;
- Машина состояний: единственный экземпляр `NORMAL ↔ PAUSED_PEAK` (`scheduler.js`), управляется одним тиком в 30 с.

### Ворота на уровне шага (v0.2.0, ключ к экономии токенов)

Подвешены на waterfall `agent/pre-step`: раунд приостанавливается **до того, как произойдёт запрос к модели следующего шага**.

- **Условия закрытия** (все обязательны): `enabled` + `stepLevelPause` + `step > 1` + пик (пекинское время, не выходные) + официальный целевой провайдер (`providerGuard`, при выключенном блокируются все) + сессия не удержана на уровне запроса + не обойдена вручную в этом пиковом окне;
- **Почему `step > 1`**: первый шаг раунда покрывается стражем уровня запроса, чтобы два gate не пересекались;
- **Пути освобождения**: ① кнопка «⏸ пауза (продолжить)» / `POST /session-guard/rpc {action:'stepResume'}` / `/resume` → пропускает текущий шаг и **больше не перекрывает эту сессию до конца пика**; ② выход из пика → всё освобождается, раунд продолжается на месте (**followup не нужен**); ③ кнопка заморозки / `/pause` / `/cancel` → ворота открываются и происходит переход к паузе на уровне раунда; ④ abort по `signal` (отмена пользователем) → открытие;
- **Повышение по таймауту**: удержание дольше `stepGateTimeoutMs` (по умолчанию 5 минут) → ворота открываются с **повышением до принудительной паузы на уровне раунда**, единообразное возобновление после пика (без дедлока и без утечки токенов по капле во время пика);
- **Состояние**: `GET /session-guard/state?session=<id>` возвращает `paused: { step, turn }` и `stepGate: { held, since, bypass }`; поле `paused` порта сервиса `state()` **остаётся булевым** (для обратной совместимости), состояние шага живёт в `pausedStep`;
- **Без записи на диск**: удержание — это Promise внутри процесса, при перезапуске исчезает (чтобы не оставались «призрачные» состояния).

#### Кнопки «Пауза сессии / Продолжить сессию» (предоставляет session-guard)

Кнопка «Пауза сессии» справа в области ввода (slot `conversation.input.right`, id `session-guard-pause`, order 20, слева от «❄ Заморозка и добавить» у input-traffic):

- не на паузе → «Пауза сессии», **нажимается**: клик вызывает `stepPause` и приостанавливает сессию **до запроса к модели следующего шага** (текущий шаг не обрывается; шаг 1 тоже перехватывается, независимо от пика и провайдера);
- на паузе → «Продолжить сессию», клик вызывает `stepResume`: пропускает текущий шаг и больше не перекрывает эту сессию до конца пика;
- **Отправка событий**: `GET /session-guard/events?session=<id>` (SSE) **мгновенно** доставляет изменения состояния ворот шага — как только пик автоматически закрывает ворота, кнопка тут же становится «Продолжить сессию», без ожидания опроса; вдобавок каждые 10 секунд опрашивается `/session-guard/state` как страховка (сходимость сохраняется и при недоступном/разорванном SSE);
- стиль выровнен с кнопками input-traffic в той же строке (высота 24 px / радиус 6 px / шрифт 12 px / те же CSS-токены), с визуальным откликом при наведении и в состоянии паузы.

### Блокировка сессии (заморозка)

- **Избыточный порт**: `ctx.provide('sessionGuard', service)` — `stopNextTurn(sessionId)` / `resume(sessionId)` / `lockQueue(sessionId)` / `unlockQueue(sessionId)` / `state(sessionId)`;
- **RPC-мост**: `POST /session-guard/rpc { action, sessionId }` — кнопка заморозки input-traffic вызывает `stopNextTurn` / `resume` **по каждой сессии** согласно `sessionId`; молча пропускается, если этот плагин не установлен (fail-open D8);
- **Ручные команды**: `/pause [force|safe] [stop|wait]`, `/resume [confirm] [rerun|skip]`, `/cancel` — действуют на сессию, из которой вызваны (берётся `invocation.agent.id`).

### Автоматический повтор на стороне бэкенда (D9)

Слушает `turn/end` и классифицирует сбои:

- **Переходящие сбои** (error/429/max-tokens и т. п.) → автоматическое возобновление `followup(retryText)` с адаптивной задержкой;
- **Постоянные сбои** (аутентификация/баланс/модель/переполнение контекста) → остановка;
- **Уступает дорогу при заморозке/воротах**: повтор не выполняется, если `isFrozen(sessionId)` истинно (queueLocked / paused / taskControl paused);
- Вмешательство пользователя или успешный раунд сбрасывают счётчик последовательных сбоев.

### Бейдж состояния (отображение на фронтенде)

Справа в области ввода показывается **чисто информационный** бейдж состояния, отражающий текущую фазу в реальном времени:

| Фаза | Текст бейджа | CSS-класс | Значение |
|---|---|---|---|
| `peak` (двухмерное определение включено) | 高峰·拦官方 | `sg-peak` | Пиковые часы, блокируются только запросы к официальным источникам DeepSeek |
| `peak` (двухмерное определение выключено) | 高峰·全部暂停 | `sg-peak` | Пиковые часы, все сессии на паузе |
| `off-peak` | 谷时 | `sg-off` | Внепиковые часы, сессии работают нормально |
| `weekend` | 周末 | `sg-weekend` | Выходные (при включённом режиме выходных), пиковая зона игнорируется |

- **Опрос**: каждые 15 секунд запрашивается `GET /session-guard/status`, чтобы получить глобальные `phase`, `providerGuard`, `held`, `deferred`, `stepHeld`;
- **fail-open**: маршрут недоступен, сетевая ошибка или `enabled` выключен → бейдж тихо скрывается, ни одна сессия не затрагивается;
- **Независимость от input-traffic**: бейдж рендерится только клиентом session-guard и отображается **без установки плагина input-traffic**. input-traffic отвечает лишь за кнопку заморозки, зависимости с бейджем нет;
- **Tooltip**: при наведении показывает `фаза · часовой пояс · режим выходных · база определения · числа удержаний/отложений/удержаний шага`.

### База определения официальных источников (providerGuard)

В пик сессии не останавливаются без разбора: сначала выясняется, «является ли маршрут, куда этот запрос реально пойдёт, официальным источником DeepSeek»:

| Приоритет | Основание | `matchedBy` | Пример |
|---|---|---|---|
| 1 | явный список id `officialProviders` | `explicit` | пользователь объявил собственный шлюз официальным |
| 2 | хост нормализованной в реальном времени `baseURL` | `endpoint` | `deepseek-official` переключён на релей → **не блокируется** |
| 3 | встроенный в каталог эндпоинт по умолчанию | `endpoint-default` | маршрут `deepseek` у pi-ai по умолчанию бьёт в официальный API → **блокируется** |
| 4 | встроенный список id (`deepseek-official`) | `route-id` | откат, когда эндпоинт прочитать нельзя |
| 5 | всё остальное | `unknown` | неофициальный, пропускается |

- **Эндпоинт важнее id**: конфигурация с именем `deepseek-official`, но с `baseURL` на релей **не** блокируется по ошибке; наоборот, встроенный у pi-ai маршрут `deepseek` с эндпоинтом по умолчанию в официальный API **не** ускользает от блокировки.
- **Источник эндпоинтов**: `ctx.get('llm').listConfigurableProviders()` находит запись каталога → `ctx.settings.get(settingsNs)` читает `baseURL` по `settingsPath` (читаются только несекретные поля, значение `apiKeyEnv` никогда не читается). Вычисляется заново на каждый запрос, без кэша → горячие правки конфигурации провайдера вступают в силу немедленно.
- **Переключение на неофициальный источник возвращает сессию**: если после пиковой паузы сессию переключить на локального/стороннего провайдера (событие `model/selection` с 0.1.2+) → эта сессия возобновляется автоматически (под условием `deferredResume`); затрагиваются только сессии, приостановленные этим плагином при входе в пик, сессии, приостановленные вручную через `/pause`, **никогда** не трогаются. Без этого события на 0.1.1 → деградация до «следующий запрос или ручной `/resume`».
- **Эндпоинт недоступен для чтения**: сервис `llm` отсутствует, структура пространства имён изменилась, поле не строка — всегда деградация к определению по id / встроенному эндпоинту с записью `matchedBy`, **исключения никогда не выбрасываются**.
- **Разбор ошибочного определения**: `GET /session-guard/provider?provider=<id>` возвращает `{ official, matchedBy, endpoint }`.

### Страж уровня запроса и отложенная очередь

- **Зачем уровень запроса**: тик в 30 с обрабатывает при скачке `NORMAL → PAUSED_PEAK` только те сессии, что были `running` в тот момент; сессии, запущенные уже после входа в пик, или переключённые на ходу на официальный источник, выпали бы. Waterfall `agent/request` — страховка, которая проходит **с каждым запросом**.
- **Определение опирается на возвращаемое значение `next()`**: middleware выбора модели внутри waterfall перезаписывает provider/model выбранными в интерфейсе значениями, поэтому сначала нужно `await next()` и только потом выносить вердикт.
- **Режим hold (по умолчанию)**: запрос удерживается, **не отправляясь и не вызывая ошибки**, и выпускается в точный момент выхода из пика (`msUntilOffPeak` — точный таймер, тик в 30 с как страховка); отмена пользователем (abort) прерывает штатно.
- **Режим error**: выбрасывается узнаваемая ошибка `PEAK_DEFERRED` + запись в отложенную очередь, после пика возобновление с `deferredResumeText` (без автоматического продолжения, если `deferredResume` выключен).
- **Защита потолком**: если `deferredMaxHoldMs` (по умолчанию 6 ч) истёк, а пик не кончился → перевод в error, чтобы не зависать бесконечно.
- **Железное правило взаимного исключения**: во время удержания **никогда** не запрашивается дополнительно пауза через сессионные ворота (пауза ждёт безопасную границу, до которой удержанный запрос никогда не дойдёт → они ожидали бы друг друга). При входе в пик уже удержанные сессии пропускаются.
- **Без персистентности**: отложенная очередь — promise внутри процесса, при перезапуске исчезает.

### Границы (явно вне рамок)

- **Никакой смены провайдера / перенаправления**: только блокировка, без маршрутизации;
- **Компактация не проходит через `agent/request`**: пока сессия на паузе, она происходить не может; вызванная вручную компрессия во время пика всё ещё может попасть в официальный источник (этот плагин не перехватывает уровень `ctx.llm.stream`);
- **На 0.1.1 нет события `model/selection`**: автоматическое возобновление после переключения на неофициальный источник деградирует до «ждать следующий запрос или ручной `/resume`» (с 0.1.2 — немедленно);
- **Никаких новых npm-зависимостей**, никаких чтения/записи учётных данных, повторы 429 / транспортного уровня у `dsh-llm-retry` не трогаются.

### Работа с часовыми поясами и валидация

- Определение часового пояса основано на **именах поясов IANA** (например `Asia/Shanghai`, `Asia/Tokyo`, `Asia/Seoul`), через `Intl.DateTimeFormat` проецируемых на настенные часы настроенного пояса, **без опоры на голый `getUTCDay()`** — это позволяет избежать классической ошибки в 8 часов на границе UTC+8 пекинского времени (суббота 00:30 по Пекину — в UTC ещё пятница);
- `Intl.DateTimeFormat` сам служит слоем валидации: недопустимое имя пояса (например `Foo/Bar`) выбрасывает `RangeError`, которую внешний try-catch тихо понижает до пояса по умолчанию `Asia/Shanghai` (fail-open);
- Окна пика **левая граница включена, правая исключена** `[start, end)`, поддерживаются окна через полночь (например `22:00–06:00`);
- Настройка `timezone` работает одинаково для всех языков интерфейса (zh/en/ja/ko) — имена поясов IANA в `Intl.DateTimeFormat` не зависят от локали: при японском/корейском интерфейсе поведение часового пояса полностью совпадает с китайским.

### Разделение труда с input-traffic: один «останавливает», другой «выстраивает очередь»

Оба действуют на **разные звенья одной цепи**, границу задаёт сама модель inbox в DSH:

```
Ввод пользователя ──(input-traffic выбирает ярус)──▶ очереди ожидания next-step / next-turn
                                        │
                          agent/pre-step ──(ворота шага этого плагина)──▶ пропустить / удержать
                                        │
                            agent/request ──(удержание уровня запроса этого плагина)──▶ пропустить / удержать
                                        │
                                     вызов модели
```

**Семантика очередей DSH (две очереди, не путать)**

| Очередь | Смысл | Когда потребляется |
|---|---|---|
| `next-step` | «Ввод, ожидающий следующей границы шага» | На следующем `agent/pre-step`: **на одном уровне с результатом инструмента**, ещё один шаг внутри того же раунда |
| `next-turn` | «Промпт, ожидающий отдельного раунда» | По завершении текущего раунда, запускается как **новый раунд** |

`Inbox.claim()` **всегда сначала выгребает `next-step`** и лишь дополнительно берёт **1** элемент `next-turn`, если на этой границе открывается новый раунд; первый шаг раунда читает next-turn, все последующие читают next-step.

**Разделение обязанностей**

- **session-guard = останавливать**: решает только, «когда можно продолжать», и **никогда не трогает содержимое и порядок очередей**.
  - ворота шага (`agent/pre-step`): удерживают **до** запроса к модели следующего шага;
  - пауза на уровне раунда (`agent.cancel({keepInbox:true})` + `goals.pause` + безопасная граница): останавливает текущий раунд, **очереди остаются как есть**;
  - страж уровня запроса (удержание на `agent/request`): удерживает **именно этот один запрос к модели**.
- **input-traffic = выстраивать очередь**: решает только, «в какую очередь попадает ввод пользователя, каким ярусом и когда будет потреблён».
  - три яруса = в какую очередь положить: красный «прервать» делает сначала `cancel()`, затем `steer`; жёлтый «вставить реплику» делает `steer` (→ `next-step`, следующий шаг того же раунда); зелёный «в очередь» остаётся в `next-turn`;
  - заморозка = извлечь строки `queued` + `steering` (ярус сохраняется) + блокировка composer + вызов `sessionGuard.stopNextTurn`; возобновление = снять блокировку → сначала `sessionGuard.resume` → повторная подача по ярусу.

**Два железных правила в точке встречи**

1. **Заморозка обязана позволить плагину сначала открыть ворота шага**: ворота шага подвешены на `agent/pre-step`, а пауза на уровне раунда ждёт события на безопасной границе — они ожидали бы друг друга (`pauseTask` / `cancelTask` этого плагина сначала делают `release`);
2. **Пока ворота шага закрыты, сообщения уже забраны**: `preStep()` делает `inbox.claim()` до диспетчеризации waterfall, поэтому новый ввод встаёт в очередь за уже забранной партией; `keepInbox` действует только на паузу уровня раунда.

**Никаких взаимных вторжений**: input-traffic не слушает `agent/pre-step` / `agent/request` (единственное исключение — явный `cancel()` яруса «прервать», запрошенный самим пользователем); этот плагин никогда не переписывает содержимое и порядок `next-step` / `next-turn`.

Кнопки: кнопки «Пауза сессии / Продолжить сессию» этого плагина (order 20) и кнопки input-traffic «❄ Заморозка и добавить / Возобновить и добавить» (order 30) стоят рядом и не заменяют друг друга — первые управляют воротами шага, вторые извлечением из очереди + заморозкой на уровне раунда.

## Избыточный порт `sessionGuard`

```js
{
  stopNextTurn(sessionId, opts),  // останавливает следующий раунд сессии (собственные сессионные ворота / откат к очереди с блокировкой)
  resume(sessionId, opts),        // возобновляет (confirm + choice: rerun|skip)
  lockQueue(sessionId, reason),   // явно блокирует очередь
  unlockQueue(sessionId),         // явно разблокирует
  stepPause(sessionId),           // вручную запрашивает паузу уровня шага (закрытие на следующей границе pre-step, шаг 1 тоже перехватывается)
  stepResume(sessionId, opts),    // открывает ворота шага (v0.2.0); при opts.bypass=false без обхода на этот пик
  state(sessionId),               // { queueLocked, lockReason, paused, pausedStep, stepHeldSince, stepBypass, taskControlAvailable, taskControl }
}
```

## HTTP-маршруты

- `GET /session-guard/state?session=<id>` — состояние сессии (включая `paused: { step, turn, manual }` / `stepGate` / последнюю цель / удержание / отложение)
- `GET /session-guard/events?session=<id>` — **SSE**: мгновенная доставка изменений состояния ворот шага (кнопка обновляется по ним)
- `GET /session-guard/settings` — настройки + доступность taskControl
- `GET /session-guard/status` — текущая глобальная фаза (опрос бейджа состояния; включает `stepHeld`)
- `GET /session-guard/provider?provider=<id>` — диагностика определения официального источника (`official` / `matchedBy` / `endpoint`)
- `GET /session-guard/diag` — диагностика времени выполнения (включая `stepGate`)
- `POST /session-guard/rpc` — `{ action: stopNextTurn|resume|lockQueue|unlockQueue|stepPause|stepResume|state, sessionId }`

## Хранение состояния

JSON на сессию: `$DSH_HOME/.dsh/session-guard/<sessionId>.json` (атомарная запись; можно переопределить через `DSH_SESSION_GUARD_STATE_DIR`).

## Тесты

```bash
npm test   # node --test tests/*.test.mjs (часовые пояса/выходные/машина состояний/сессионные ворота/мост/повтор)
```

## Модули

| Файл | Ответственность |
|---|---|
| `src/time.js` | Определение пика/выходных (с правильным поясом) + `msUntilOffPeak` (точный таймер выхода из пика) |
| `src/scheduler.js` | Чистая машина состояний NORMAL ↔ PAUSED_PEAK |
| `src/provider.js` | Пятиуровневое определение официальных источников (чистая функция: нормализация эндпоинта + матрица решений) |
| `src/provider-directory.js` | Каталог эндпоинтов (`llm.listConfigurableProviders` + `settings.get`, деградация по всей цепочке) |
| `src/deferrals.js` | Реестр отложений (удержание / освобождение / превышение предела / `PeakDeferredError`) |
| `src/request-guard.js` | Страж уровня запроса на `agent/request` (два режима hold / error) |
| `src/step-gate.js` | **Ворота уровня шага на `agent/pre-step`** (v0.2.0: закрытие / открытие / повышение по таймауту / bypass, чистый `decideStepHold` покрыт юнит-тестами) |
| `src/targets.js` | Отслеживание «последней реальной цели» сессии (`request/header` + `model/selection`) |
| `src/wiring.js` | Оркестрация подключения (фильтр входа в пик / подключение ворот шага / освобождение после пика / точный таймер / очистка при выгрузке) |
| `src/pause-gate.js` | Движок собственных сессионных ворот (agent.cancel keepInbox + goals.pause + безопасная граница + возобновление followup; перед паузой сначала открывает ворота шага) |
| `src/pause-store.js` | Персистентность собственного состояния паузы |
| `src/gate.js` | Драйвер сессионных ворот (собственная настоящая пауза / откат к очереди с блокировкой, fail-open) |
| `src/bridge.js` | Избыточный порт `sessionGuard` |
| `src/retry.js` | Автоповтор на бэкенде (классификация сбоев/задержка/уступание при заморозке; короткое замыкание только по точному коду `PEAK_DEFERRED`) |
| `src/detect.js` | Автоопределение (taskControl хоста / мост клиента input-traffic) |
| `src/store.js` | Персистентное состояние на сессию |
| `src/settings.js` | Подраздел настроек (схема schemastery + fail-open регистрация) |
| `src/index.js` | apply на стороне хоста (настройки/маршруты/тик/предоставление сервиса/подключение повтора/страж запросов) |
| `src/client/` | Браузерная половина (**кнопка паузы сессии** + бейдж состояния + карточка настроек) |

## Лицензия

MIT — см. [LICENSE](LICENSE).
