dsh-session-guard 0.1.5-beta.1 → 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.ja.md CHANGED
@@ -6,6 +6,29 @@
6
6
  - [日本語 changelog](./CHANGELOG.ja.md)
7
7
  - [한국어 changelog](./CHANGELOG.ko.md)
8
8
 
9
+ ## 0.2.0-beta.1 — 2026-09-10
10
+
11
+ ### 追加
12
+
13
+ - **step 級ゲート(`agent/pre-step`)**:ピーク時、ターン境界で中断するのではなく、**次の step のモデルリクエスト前**にターンを保留します。セッションは次の `agent/pre-step` まで走り、そこでゲートが閉じます(設定 `stepLevelPause`、既定 on)。退峰時は**その場で**再開し、followup メッセージは不要。ゲート条件:ピーク(北京時間)+ 非週末 + `step > 1` + 対象 provider が公式(`providerGuard`)+ リクエスト級 hold なし + このピーク期間でスキップなし。新規モジュール `src/step-gate.js`(純関数 `decideStepHold` + hold / release / abort / timeout エンジン)。
14
+ - **`stepResume` ポート + RPC + `/resume`**:`sessionGuard.stepResume(sessionId, {bypass})`、`POST /session-guard/rpc {action:'stepResume'}`、`/resume` のいずれでもゲートを解放。手動再開はそのピーク期間中のゲートを停止します。
15
+ - **タイムアウト昇格**:`stepGateTimeoutMs`(既定 300000)でゲートを解放し**ターン級 force 一時停止へ昇格**。長時間ピークでもデッドロックせず、「5 分ごとに 1 step」の滴漏も起きません。
16
+ - **「⏸ 一時停止」ボタン**(クライアント、slot `conversation.input.right`、id `session-guard-pause`、order 20 — input-traffic の凍結ボタンの左)。1 秒ごとに `/session-guard/state` をポーリングし、未保留時は無効、保留時は `stepResume` を呼びます。バッジは order 40 へ移動し、step 保留数を表示。
17
+ - **新規設定**:`stepLevelPause`、`stepGateTimeoutMs`。
18
+ - **新規状態**:`GET /session-guard/state` が `paused: { step, turn }` と `stepGate: { held, since, bypass }` を返し、`/status` は `stepHeld`、`/diag` は `stepGate` を返します。サービス側 `state().paused` は互換のため真偽値のまま(新フィールド `pausedStep`)。
19
+
20
+ ### 修正
21
+
22
+ - **step 保留とターン級一時停止のデッドロック**:`pauseTask` / `resumeTask` / `cancelTask` が先に step ゲートを解放します。step 保留は `agent/pre-step` 上にあり `assistant/message` / `tool/result` が永遠に来ないため、`safe` 一時停止が永久に待ち、`paused` も永続化されませんでした。
23
+
24
+ ### 変更
25
+
26
+ - **ピーク入りで実行中ターンを中断しなくなりました**(`stepLevelPause` 有効時、`onEnterPeak` は `stopNextTurn` ではなく step ゲートを arm)。無効時は従来のターン級動作のままです。
27
+ - input-traffic の凍結ボタンのラベルは **「凍結して追加」** になりました(`冻结追加` / `Freeze & append` / `동결 후 추가`)。再開ラベルは **「再開して追加」**(`恢复追加` / `Resume & append` / `재개 후 추가`)——ターンを凍結しつつキューを保持する動作で、一時停止ボタンとは別物です。
28
+ - **一時停止ボタンはトグルになりました**:「一時停止」/「再開」(グレー無効状態は廃止)。「一時停止」は新規 `stepPause` を呼び、**次の step 境界**でセッションを保留します(step 1 も対象、峰谷 / provider の制限なし)。「再開」は `stepResume`。新ポートメソッド `sessionGuard.stepPause(sessionId)`。
29
+ - **SSE プッシュ**:新ルート `GET /session-guard/events?session=<id>` が step ゲート状態の変化を即時配信——ピークで自動的に閉じた瞬間にボタンが「再開」へ変わります。10 秒ポーリングはフォールバックとして残ります。`/state` は `paused.manual` と `stepGate.manual` を返すようになりました。
30
+ - **スタイルを input-traffic のコンポーザーボタンに揃えました**(高さ 24px / 角丸 6px / 12px フォント / 同じ border・hover・pressed トークン)。ボタンとステータスバッジの両方。スタイルは `<style data-plugin-css="session-guard-client">` で一度だけ注入。
31
+
9
32
  ## Unreleased
10
33
 
11
34
  ### 追加
package/CHANGELOG.ko.md CHANGED
@@ -6,6 +6,29 @@
6
6
  - [日本語 changelog](./CHANGELOG.ja.md)
7
7
  - [한국어 changelog](./CHANGELOG.ko.md)
8
8
 
9
+ ## 0.2.0-beta.1 — 2026-09-10
10
+
11
+ ### 추가
12
+
13
+ - **step급 게이트(`agent/pre-step`)**: 피크 시간에 턴 경계에서 중단하는 대신 **다음 step의 모델 요청 전에** 턴을 보류합니다. 세션은 다음 `agent/pre-step`까지 진행하고 거기서 게이트가 닫힙니다(설정 `stepLevelPause`, 기본 on). 오피크에는 **그 자리에서** 재개되며 followup 메시지가 필요 없습니다. 게이트 조건: 피크(북경 시간) + 주말 아님 + `step > 1` + 대상 provider가 공식(`providerGuard`) + 요청급 hold 아님 + 이번 피크 구간에서 스킵 아님. 신규 모듈 `src/step-gate.js`(순수 `decideStepHold` + hold / release / abort / timeout 엔진).
14
+ - **`stepResume` 포트 + RPC + `/resume`**: `sessionGuard.stepResume(sessionId, {bypass})`, `POST /session-guard/rpc {action:'stepResume'}`, `/resume` 모두 게이트를 해제합니다. 수동 재개는 해당 피크 구간 동안 게이트를 중단합니다.
15
+ - **타임아웃 승격**: `stepGateTimeoutMs`(기본 300000)로 게이트를 해제하고 **턴급 force 일시정지로 승격**합니다. 긴 피크에서도 교착이 없고 "5분마다 1 step" 누수도 없습니다.
16
+ - **"⏸ 일시정지" 버튼**(클라이언트, slot `conversation.input.right`, id `session-guard-pause`, order 20 — input-traffic 동결 버튼 왼쪽). 1초마다 `/session-guard/state`를 폴링하며, 미보류 시 비활성, 보류 시 `stepResume`을 호출합니다. 배지는 order 40으로 이동하고 step 보류 수를 표시합니다.
17
+ - **신규 설정**: `stepLevelPause`, `stepGateTimeoutMs`.
18
+ - **신규 상태**: `GET /session-guard/state`가 `paused: { step, turn }`과 `stepGate: { held, since, bypass }`를 반환하고, `/status`는 `stepHeld`, `/diag`는 `stepGate`를 반환합니다. 서비스 포트 `state().paused`는 호환을 위해 불리언 유지(신규 필드 `pausedStep`).
19
+
20
+ ### 수정
21
+
22
+ - **step 보류와 턴급 일시정지의 교착**: `pauseTask` / `resumeTask` / `cancelTask`가 먼저 step 게이트를 해제합니다. step 보류는 `agent/pre-step`에 있어 `assistant/message` / `tool/result`가 영원히 오지 않으므로, `safe` 일시정지가 무한 대기하고 `paused`도 영속화되지 않았습니다.
23
+
24
+ ### 변경
25
+
26
+ - **피크 진입 시 실행 중 턴을 중단하지 않습니다**(`stepLevelPause` on일 때 `onEnterPeak`는 `stopNextTurn` 대신 step 게이트를 arm). off일 때는 기존 턴급 동작 그대로입니다.
27
+ - input-traffic 동결 버튼 라벨이 **"동결 후 추가"**로 바뀌었고(`冻结追加` / `Freeze & append` / `凍結して追加`), 재개 라벨은 **"재개 후 추가"**(`恢复追加` / `Resume & append` / `再開して追加`)입니다 — 턴을 동결하면서 큐를 보존하는 동작으로, 일시정지 버튼과는 다릅니다.
28
+ - **일시정지 버튼이 토글이 되었습니다**: "일시정지" / "재개" (회색 비활성 상태 제거). "일시정지"는 새 `stepPause`를 호출해 **다음 step 경계**에서 세션을 보류합니다(step 1도 대상, 피크/provider 제한 없음). "재개"는 `stepResume`. 신규 포트 메서드 `sessionGuard.stepPause(sessionId)`.
29
+ - **SSE push**: 새 라우트 `GET /session-guard/events?session=<id>`가 step 게이트 상태 변화를 즉시 전달 — 피크에서 자동으로 닫히는 순간 버튼이 "재개"로 바뀝니다. 10초 폴링은 폴백으로 남습니다. `/state`는 `paused.manual`과 `stepGate.manual`을 반환합니다.
30
+ - **스타일을 input-traffic 컴포저 버튼과 맞췄습니다**(높이 24px / 반경 6px / 12px 글꼴 / 동일 border·hover·pressed 토큰). 버튼과 상태 배지 모두. 스타일은 `<style data-plugin-css="session-guard-client">`로 한 번만 주입.
31
+
9
32
  ## Unreleased
10
33
 
11
34
  ### 추가
package/CHANGELOG.md CHANGED
@@ -6,6 +6,58 @@ All notable changes to `dsh-session-guard` are recorded here. Versions follow se
6
6
  - [日本語 changelog](./CHANGELOG.ja.md)
7
7
  - [한국어 changelog](./CHANGELOG.ko.md)
8
8
 
9
+ ## 0.2.0-beta.1 — 2026-09-10
10
+
11
+ ### Added
12
+
13
+ - **Step-level gate (`agent/pre-step`).** During peak hours the turn is now held **before** the
14
+ next step's model request instead of being interrupted at a turn boundary: the session keeps
15
+ running until the next `agent/pre-step`, where the gate holds it (setting `stepLevelPause`, on by
16
+ default). The turn resumes **in place** off-peak — no followup message needed. Hold conditions:
17
+ peak (Beijing time) + not weekend + `step > 1` + official target provider (`providerGuard`) +
18
+ not request-held + not bypassed in this peak window. New module `src/step-gate.js` (pure
19
+ `decideStepHold` + hold / release / abort / timeout engine).
20
+ - **`stepResume` port + RPC + `/resume`.** `sessionGuard.stepResume(sessionId, {bypass})`,
21
+ `POST /session-guard/rpc {action:'stepResume'}` and `/resume` all release the gate; a manual
22
+ resume also stops gating that session for the rest of the peak window.
23
+ - **Timeout escalation.** `stepGateTimeoutMs` (default 300000) releases the gate and escalates to a
24
+ turn-level **force** pause, so a long peak neither deadlocks nor drips one step every five minutes.
25
+ - **"⏸ Pause session" button** (client, slot `conversation.input.right`, id `session-guard-pause`,
26
+ order 20 — left of input-traffic's freeze button). It polls `/session-guard/state` once a second,
27
+ stays disabled while nothing is held, and calls `stepResume` when it is. The status badge moved to
28
+ order 40 and now reports the number of step-held sessions.
29
+ - **New settings**: `stepLevelPause`, `stepGateTimeoutMs`.
30
+ - **New state**: `GET /session-guard/state` now returns `paused: { step, turn }` and
31
+ `stepGate: { held, since, bypass }`; `/status` returns `stepHeld`; `/diag` returns `stepGate`.
32
+ The service port's `state().paused` stays boolean for compatibility (new field `pausedStep`).
33
+
34
+ ### Fixed
35
+
36
+ - **Deadlock between a held step and a turn-level pause.** `pauseTask` / `resumeTask` /
37
+ `cancelTask` now release the step gate first: a step hold sits at `agent/pre-step`, where no
38
+ `assistant/message` or `tool/result` can ever arrive, so a `safe` pause used to wait forever and
39
+ never persisted `paused`.
40
+
41
+ ### Changed
42
+
43
+ - **Peak entry no longer interrupts running turns** when `stepLevelPause` is on (`onEnterPeak` arms
44
+ the step gate instead of calling `stopNextTurn`); with it off the previous turn-level behaviour is
45
+ unchanged.
46
+ - input-traffic's freeze button label is now **"Freeze & append"** (`冻结追加` / `凍結して追加` /
47
+ `동결 후 추가`), and its resume label **"Resume & append"** (`恢复追加` / `再開して追加` /
48
+ `재개 후 추가`) — it freezes the turn and keeps queued messages, distinct from the pause button.
49
+ - **The pause button is a toggle now**: "Pause session" / "Resume session" (no disabled grey state).
50
+ Clicking "Pause session" calls the new `stepPause` action, which holds the session at the **next
51
+ step boundary** (step 1 included, regardless of peak or provider); "Resume session" calls
52
+ `stepResume`. New port method `sessionGuard.stepPause(sessionId)`.
53
+ - **SSE push**: new route `GET /session-guard/events?session=<id>` pushes step-gate state changes the
54
+ moment they happen, so peak auto-holds flip the button to "Resume session" without waiting for a
55
+ poll; the 10s `/session-guard/state` poll remains as a fallback. `/state` now reports
56
+ `paused.manual` and `stepGate.manual`.
57
+ - **Styling aligned** with input-traffic's composer button (24px height, 6px radius, 12px font, the
58
+ same border/hover/pressed tokens) for both the pause button and the status badge; styles are
59
+ injected once via `<style data-plugin-css="session-guard-client">`.
60
+
9
61
  ## Unreleased
10
62
 
11
63
  ### Added
package/README.en.md CHANGED
@@ -76,6 +76,7 @@ Restart dsh web and refresh the page after installation.
76
76
  | Toggle | Default | Description |
77
77
  |---|---|---|
78
78
  | `enabled` | on | **Peak auto-pause**: auto-pause running sessions during peak hours |
79
+ | `stepLevelPause` | on | **Step-level gate**: during peak, hold *before* the next step's model request (earlier and cheaper than turn-level); off = fall back to turn-level pause |
79
80
  | `providerGuard` | on | **Official-source guard**: block only DeepSeek official sources during peak; local/third-party providers keep running |
80
81
  | `guardSubagents` | on | **Guard subagent requests**: subagent requests are billed too, guarded by default |
81
82
  | `offPeakAutoResume` | on | **Off-peak auto-resume**: auto-resume paused sessions off-peak; off = no auto-resume (manual required) |
@@ -89,6 +90,7 @@ Additional configuration:
89
90
  - `timezone` (default Asia/Shanghai) — used for **weekend detection** and badge display; **does not affect peak/off-peak detection** (always Beijing time);
90
91
  - `peakWindows` (default 09:00–12:00 / 14:00–18:00) — peak windows in Beijing time (UTC+8), matching DeepSeek's official billing;
91
92
  - `pauseMode` (`safe`/`force`), `pauseReason` (`wait`/`stop`);
93
+ - `stepGateTimeoutMs` (default 300000) — step-gate hold timeout; on expiry the gate is released and the session **escalates to a turn-level pause** (anti-deadlock, and no "one step per 5 minutes" token drip);
92
94
  - Official-source guard: `officialProviders` (extra official provider ids, comma separated, highest priority), `officialBaseURLs` (official endpoint hosts, default `api.deepseek.com`);
93
95
  - Deferral queue: `deferredMode` (`hold` / `error`), `deferredResumeText`, `deferredMaxHoldMs` (hold cap, default 6h, then converts to an error);
94
96
  - Retry parameters: `retryText`, `retryGraceMs`, `retryCooldownMs`, `retryBackoffFactor`, `retryBackoffMaxMs`, `retryMaxConsecutive`.
@@ -97,11 +99,31 @@ Additional configuration:
97
99
 
98
100
  ### Peak auto-gate (global)
99
101
 
100
- - **Peak entry** (and not weekend): calls `gate.stopNextTurn` on all running root sessionscustom session gate truly pauses (doesn't interrupt reasoning, pauses at safe boundary before next tool dispatch), or falls back to lock-wait queue per `queueFallback`;
101
- - **Off-peak / weekend**: `gate.resume` **all** sessions (auto-resume, no manual action) — controlled by `offPeakAutoResume` toggle;
102
+ - **Peak entry** (and not weekend): with `stepLevelPause` on, the running turn is **no longer interrupted** the session runs to the next `agent/pre-step` boundary where the step gate holds it (see below); with it off, calls `gate.stopNextTurn` on all running root sessions (custom session gate truly pauses, or falls back to lock-wait queue per `queueFallback`);
103
+ - **Off-peak / weekend**: first `releaseAll` the held steps (the turn just continues in place), then `gate.resume` **all** sessions — controlled by `offPeakAutoResume`;
102
104
  - **Peak timezone**: hardcoded to Beijing time (`Asia/Shanghai`), matching DeepSeek's official billing basis — not affected by the `timezone` setting;
103
105
  - State machine: single-instance `NORMAL ↔ PAUSED_PEAK` (`scheduler.js`), driven by a single 30s tick.
104
106
 
107
+ ### Step-level gate (v0.2.0, the token saver)
108
+
109
+ Hooks the `agent/pre-step` waterfall and holds the turn **before the next step's model request happens**.
110
+
111
+ - **Hold conditions** (all required): `enabled` + `stepLevelPause` + `step > 1` + peak (Beijing time, not weekend) + official target provider (`providerGuard`; all providers when off) + the session is not request-held + not manually bypassed this peak window;
112
+ - **Why `step > 1`**: the first step of a turn is covered by the request-level guard, so the two gates never overlap;
113
+ - **Release paths**: ① the "⏸ paused (resume)" button / `POST /session-guard/rpc {action:'stepResume'}` / `/resume` → lets the current step through and **stops gating this session for the rest of the peak window**; ② off-peak → release all, the turn continues in place (**no followup needed**); ③ freeze button / `/pause` / `/cancel` → release the gate and move to a turn-level pause; ④ `signal` abort → release;
114
+ - **Timeout escalation**: holding longer than `stepGateTimeoutMs` (default 5 min) releases the gate and **escalates to a turn-level force pause**, resumed off-peak (no deadlock, no token drip);
115
+ - **State**: `GET /session-guard/state?session=<id>` returns `paused: { step, turn }` and `stepGate: { held, since, bypass }`; the service port's `paused` **stays boolean** for compatibility, with `pausedStep` for the step gate;
116
+ - **Not persisted**: the hold is an in-process promise; a restart drops it (no ghost state).
117
+
118
+ #### Pause / resume session button (provided by session-guard)
119
+
120
+ The "Pause session" button in the composer's right row (slot `conversation.input.right`, id `session-guard-pause`, order 20, left of input-traffic's "❄ Freeze & append"):
121
+
122
+ - not paused → "Pause session", **clickable**: calls `stepPause` and pauses the session **before the next step's model request** (the current step is not interrupted; step 1 is held too, regardless of peak/provider);
123
+ - paused → "Resume session", calls `stepResume`: lets the current step through and stops gating this session for the rest of the peak window;
124
+ - **push updates**: `GET /session-guard/events?session=<id>` (SSE) pushes step-gate state changes **immediately** — when peak auto-holds, the button flips to "Resume session" without waiting for a poll; a 10s `/session-guard/state` poll remains as a fallback (SSE down → still converges);
125
+ - styled to match input-traffic's button in the same row (24px height / 6px radius / 12px font / same CSS tokens), with hover and paused states.
126
+
105
127
  ### Session locking (freeze)
106
128
 
107
129
  - **Redundant port**: `ctx.provide('sessionGuard', service)` — `stopNextTurn(sessionId)` / `resume(sessionId)` / `lockQueue(sessionId)` / `unlockQueue(sessionId)` / `state(sessionId)`;
@@ -176,11 +198,47 @@ During peak hours the plugin does not blanket-pause sessions: it first decides w
176
198
  - Peak windows are **left-closed, right-open** `[start, end)`, supporting cross-midnight windows (e.g. `22:00–06:00`);
177
199
  - The `timezone` setting works identically across all UI languages (zh/en/ja/ko) — IANA timezone names are locale-independent.
178
200
 
179
- ### Coordination with input-traffic
201
+ ### Division of labour with input-traffic: one "stops", one "orders"
202
+
203
+ They act on **different links of the same chain**, and the boundary is set by DSH's own inbox model:
204
+
205
+ ```
206
+ user input ──(input-traffic picks the tier)──▶ next-step / next-turn pending queues
207
+
208
+ agent/pre-step ──(this plugin's step gate)──▶ pass / hold
209
+
210
+ agent/request ──(this plugin's request hold)──▶ pass / hold
211
+
212
+ model call
213
+ ```
214
+
215
+ **DSH queue semantics (two queues — don't mix them up)**
216
+
217
+ | Queue | Meaning | Consumed when |
218
+ |---|---|---|
219
+ | `next-step` | "Input awaiting the next step boundary" | The next `agent/pre-step`: **same level as a tool result**, another step inside the same turn |
220
+ | `next-turn` | "Prompts awaiting individual turns" | After the current turn closes, as a **new turn** |
221
+
222
+ `Inbox.claim()` **always drains `next-step` first**, and only additionally takes **one** `next-turn` when that boundary opens a new turn; a turn's first step reads next-turn, every later step reads next-step.
223
+
224
+ **Ownership**
225
+
226
+ - **session-guard = stop**: decides *when progress may happen*, and **never touches queue content or order**.
227
+ - step gate (`agent/pre-step`): holds **before** the next step's model request;
228
+ - turn-level pause (`agent.cancel({keepInbox:true})` + `goals.pause` + safe boundary): stops the turn, **queue preserved as-is**;
229
+ - request-level guard (`agent/request` hold): holds **this one model request**.
230
+ - **input-traffic = order**: decides *which queue user input goes to, at what tier, and when it is consumed*.
231
+ - three tiers = which queue: red "interrupt" calls `cancel()` then `steer`; yellow "steer" calls `steer` (→ `next-step`, the same turn's next step); green "queue" stays in `next-turn`;
232
+ - freeze = detach all `queued` + `steering` rows (tiers preserved) + composer block + call `sessionGuard.stopNextTurn`; resume = clear the block → `sessionGuard.resume` first → re-submit by tier.
233
+
234
+ **Two invariants at the meeting point**
235
+
236
+ 1. **Freeze must let this plugin release the step gate first**: the step gate sits at `agent/pre-step` while a turn-level pause waits for a safe-boundary event — they would wait on each other (`pauseTask` / `cancelTask` release it first);
237
+ 2. **While the step gate is held, messages are already claimed**: `preStep()` calls `inbox.claim()` *before* dispatching the waterfall, so new input queues behind the claimed batch; `keepInbox` only applies to turn-level pauses.
238
+
239
+ **No crossing over**: input-traffic does not listen to `agent/pre-step` / `agent/request` (the only exception is the "interrupt" tier's explicit `cancel()`, which the user asked for); this plugin never rewrites `next-step` / `next-turn` content or order.
180
240
 
181
- - input-traffic's **freeze button** triggers via `sessionGuard.stopNextTurn` (RPC, per-session) on the server side;
182
- - input-traffic **only does freeze enhancement** (queue freeze/unfreeze + composer block), retry is handled by this plugin's backend;
183
- - Both share "session isolation" semantics: input-traffic freeze queue keyed by sessionId, session-guard RPC also keyed by sessionId.
241
+ Buttons: this plugin's "Pause session / Resume session" (order 20) and input-traffic's "❄ Freeze & append / Resume & append" (order 30) sit side by side and replace neither — the former owns the step gate, the latter owns queue detach + turn-level freeze.
184
242
 
185
243
  ## Redundant port `sessionGuard`
186
244
 
@@ -190,18 +248,21 @@ During peak hours the plugin does not blanket-pause sessions: it first decides w
190
248
  resume(sessionId, opts),
191
249
  lockQueue(sessionId, reason),
192
250
  unlockQueue(sessionId),
193
- state(sessionId),
251
+ stepPause(sessionId), // request a manual step-level pause (held at the next pre-step)
252
+ stepResume(sessionId, opts), // release the step gate (v0.2.0); opts.bypass=false keeps gating this peak
253
+ state(sessionId), // { queueLocked, lockReason, paused, pausedStep, stepHeldSince, stepBypass, ... }
194
254
  }
195
255
  ```
196
256
 
197
257
  ## HTTP routes
198
258
 
199
- - `GET /session-guard/state?session=<id>` — session state (last target / held / deferred)
259
+ - `GET /session-guard/state?session=<id>` — session state (`paused: { step, turn, manual }` / `stepGate` / last target / held / deferred)
260
+ - `GET /session-guard/events?session=<id>` — **SSE**: pushes step-gate state changes immediately (drives the button)
200
261
  - `GET /session-guard/settings` — settings + taskControl availability
201
- - `GET /session-guard/status` — global current phase (status badge polling)
262
+ - `GET /session-guard/status` — global current phase (status badge polling; includes `stepHeld`)
202
263
  - `GET /session-guard/provider?provider=<id>` — official-source verdict diagnostics (`official` / `matchedBy` / `endpoint`)
203
- - `GET /session-guard/diag` — runtime diagnostics
204
- - `POST /session-guard/rpc` — `{ action: stopNextTurn|resume|lockQueue|unlockQueue|state, sessionId }`
264
+ - `GET /session-guard/diag` — runtime diagnostics (includes `stepGate`)
265
+ - `POST /session-guard/rpc` — `{ action: stopNextTurn|resume|lockQueue|unlockQueue|stepPause|stepResume|state, sessionId }`
205
266
 
206
267
  ## State storage
207
268
 
@@ -223,9 +284,10 @@ npm test # node --test tests/*.test.mjs (timezone/weekend/state-machine/sessio
223
284
  | `src/provider-directory.js` | Endpoint directory (`llm.listConfigurableProviders` + `settings.get`, full degradation) |
224
285
  | `src/deferrals.js` | Deferral registry (hold / release / cap / `PeakDeferredError`) |
225
286
  | `src/request-guard.js` | `agent/request` request-level guard (hold / error modes) |
287
+ | `src/step-gate.js` | **`agent/pre-step` step-level gate** (v0.2.0: hold / release / timeout escalation / bypass; pure `decideStepHold`) |
226
288
  | `src/targets.js` | Per-session "last real target" tracking (`request/header` + `model/selection`) |
227
- | `src/wiring.js` | Wiring/orchestration (peak-entry filter / off-peak release / exact timer / dispose) |
228
- | `src/pause-gate.js` | Custom session gate engine |
289
+ | `src/wiring.js` | Wiring/orchestration (peak-entry filter / step-gate wiring / off-peak release / exact timer / dispose) |
290
+ | `src/pause-gate.js` | Custom session gate engine (releases the step gate before pausing) |
229
291
  | `src/pause-store.js` | Custom pause state persistence |
230
292
  | `src/gate.js` | Session gate driver (custom true pause / fallback lock queue, fail-open) |
231
293
  | `src/bridge.js` | `sessionGuard` redundant port |
package/README.ja.md CHANGED
@@ -55,6 +55,7 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
55
55
  | スイッチ | デフォルト | 説明 |
56
56
  |---|---|---|
57
57
  | `enabled` | on | **ピーク自動一時停止**:ピーク時間帯に実行セッションを自動一時停止 |
58
+ | `stepLevelPause` | on | **step 級ゲート**:ピーク時、次の step のモデルリクエスト**前**にゲートを閉じる(ターン級より早く・より節約)。オフでターン級一時停止にフォールバック |
58
59
  | `providerGuard` | on | **公式ソース二次判定**:ピーク時は DeepSeek 公式ソースのみ遮断、ローカル/第三者 provider は通常実行 |
59
60
  | `guardSubagents` | on | **サブエージェントも対象**:サブエージェントのリクエストも課金対象、既定で遮断 |
60
61
  | `offPeakAutoResume` | on | **オフピーク自動再開**:オフピーク時に一時停止セッションを自動再開 |
@@ -68,6 +69,7 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
68
69
  - `timezone`(デフォルト Asia/Shanghai)——**週末判定**とバッジ表示に使用。**峰谷判定には影響しない**(峰谷は常に北京時間);
69
70
  - `peakWindows`(デフォルト 09:00–12:00 / 14:00–18:00)——北京時間(UTC+8)の峰谷ウィンドウ。DeepSeek 公式課金と一致;
70
71
  - `pauseMode`(`safe`/`force`)、`pauseReason`(`wait`/`stop`);
72
+ - `stepGateTimeoutMs`(既定 300000)——step ゲートの保留タイムアウト。期限切れでゲートを解放し**ターン級一時停止へ昇格**(デッドロック回避、「5 分ごとに 1 step」のトークン滴漏も回避);
71
73
  - 公式ソース判定:`officialProviders`(公式 provider id を追加、カンマ区切り、最優先)、`officialBaseURLs`(公式エンドポイント host、既定 `api.deepseek.com`);
72
74
  - 延後キュー:`deferredMode`(`hold` / `error`)、`deferredResumeText`、`deferredMaxHoldMs`(保留上限、既定 6h、超過で error);
73
75
  - リトライパラメータ:`retryText`、`retryGraceMs`、`retryCooldownMs`、`retryBackoffFactor`、`retryBackoffMaxMs`、`retryMaxConsecutive`。
@@ -76,11 +78,31 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
76
78
 
77
79
  ### ピーク自動ゲート(グローバル)
78
80
 
79
- - **ピーク入り**(かつ非週末):全 running ルートセッションに `gate.stopNextTurn` を呼び出し——カスタムセッションゲートで真の一時停止(推論を中断せず、安全境界で一時停止)、`queueFallback` でロック待機キューにフォールバック;
80
- - **退峰 / 週末**:`gate.resume` **全**セッション(自動再開、手動不要)——`offPeakAutoResume` スイッチで制御;
81
+ - **ピーク入り**(かつ非週末):`stepLevelPause` が有効なら**ターンを即中断しない**——セッションは次の `agent/pre-step` 境界まで走り、そこで step ゲートが閉じます(下記)。無効なら全 running ルートセッションに `gate.stopNextTurn`(カスタムセッションゲート、`queueFallback` でロック待機キューにフォールバック);
82
+ - **退峰 / 週末**:まず保留中の step を `releaseAll` で解放(ターンはその場で継続)、次に `gate.resume` **全**セッション(`offPeakAutoResume` で制御);
81
83
  - **峰谷タイムゾーン**:常に北京時間(`Asia/Shanghai`)を使用。DeepSeek 公式課金基準に一致。`timezone` 設定の影響を受けません;
82
84
  - 状態機械:単一インスタンス `NORMAL ↔ PAUSED_PEAK`(`scheduler.js`)、単一 30s tick で駆動。
83
85
 
86
+ ### step 級ゲート(v0.2.0、節約の要)
87
+
88
+ `agent/pre-step` waterfall に接続し、**次の step のモデルリクエストが発生する前**にターンを保留します。
89
+
90
+ - **ゲート条件**(すべて満たす):`enabled` + `stepLevelPause` + `step > 1` + ピーク(北京時間、非週末)+ 対象 provider が公式(`providerGuard`、オフなら全部)+ リクエスト級 hold されていない + このピーク期間で手動スキップされていない;
91
+ - **`step > 1` の理由**:ターン最初の step はリクエスト級ガードが担当するため、二重ゲートにならない;
92
+ - **解放経路**:①「⏸ 一時停止中(再開)」ボタン / `POST /session-guard/rpc {action:'stepResume'}` / `/resume` → 現在の step を通し、**このピーク期間はもうゲートしない**;② 退峰 → 全解放、ターンはその場で継続(**followup 不要**);③ 凍結ボタン / `/pause` / `/cancel` → ゲート解放してターン級一時停止へ;④ `signal` abort → 解放;
93
+ - **タイムアウト昇格**:`stepGateTimeoutMs`(既定 5 分)超過でゲート解放 + **ターン級 force 一時停止へ昇格**、退峰で復帰(デッドロックなし・トークン滴漏なし);
94
+ - **状態**:`GET /session-guard/state?session=<id>` が `paused: { step, turn }` と `stepGate: { held, since, bypass }` を返す。サービス側 `state().paused` は**互換のため真偽値のまま**、step 状態は `pausedStep`;
95
+ - **永続化しない**:保留はプロセス内 Promise、再起動で消滅(幽霊状態を避ける)。
96
+
97
+ #### 「一時停止 / 再開」ボタン(session-guard が提供)
98
+
99
+ コンポーザー右側の「一時停止」ボタン(slot `conversation.input.right`、id `session-guard-pause`、order 20 — input-traffic の「❄ 凍結して追加」の左):
100
+
101
+ - 未一時停止 → 「一時停止」、**クリック可**:`stepPause` を呼び、**次の step のモデルリクエスト前**にセッションを停止(現在の step は中断しない。step 1 も対象で、峰谷 / provider の制限を受けない);
102
+ - 一時停止中 → 「再開」、`stepResume` を呼び現在の step を放行、このピーク期間はもうゲートしない;
103
+ - **イベント push**:`GET /session-guard/events?session=<id>`(SSE)が step ゲート状態の変化を**即時**通知——ピークで自動的に閉じたらボタンは即「再開」に変わります。10 秒ごとの `/session-guard/state` ポーリングはフォールバック(SSE 不通でも収束);
104
+ - 同じ行の input-traffic ボタンと見た目を揃えています(高さ 24px / 角丸 6px / 12px フォント / 同じ CSS トークン)。
105
+
84
106
  ### 公式ソース判定(providerGuard)
85
107
 
86
108
  ピーク時は無差別停止ではなく、まず「このリクエストが実際に向かうルートが DeepSeek 公式ソースか」を判定します。
@@ -134,15 +156,21 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
134
156
  | `off-peak` | 谷時 | `sg-off` | オフピーク時間帯、セッション通常稼働 |
135
157
  | `weekend` | 週末 | `sg-weekend` | 週末(週末モード有効時)、峰谷無視 |
136
158
 
137
- - 15 秒ごとに `GET /session-guard/status` をポーリング(`phase` / `providerGuard` / `held` / `deferred`);
159
+ - 15 秒ごとに `GET /session-guard/status` をポーリング(`phase` / `providerGuard` / `held` / `deferred` / `stepHeld`);
138
160
  - fail-open:ルート到達不可・ネットワークエラー・`enabled` オフ時→バッジ非表示;
139
161
  - **input-traffic に依存しない**:session-guard クライアントコードが単独で描画。input-traffic は凍結ボタンのみ担当;
140
162
 
141
163
  ### input-traffic との連携
142
164
 
143
- - input-traffic の**凍結ボタン**は `sessionGuard.stopNextTurn`(RPC、セッション経由)経由でサーバーサイドに伝達;
144
- - input-traffic は**凍結強化のみ**(キュー凍結/解凍 + composer ブロック)、リトライは本プラグインのバックエンドが処理;
145
- - 両方「セッション分離」セマンティスを共有:input-traffic 凍結キューは sessionId で分離、session-guard RPC も sessionId で分離。
165
+ - input-traffic の**「❄ 凍結して追加」ボタン**は `sessionGuard.stopNextTurn`(RPC、セッション経由)で伝達し、**まず step ゲートを解放**します(そうしないとターン級一時停止が永遠に来ない安全境界を待ち、相互待機になります);
166
+ ### input-traffic との役割分担:「止める」側と「並べる」側
167
+
168
+ **DSH のキュー意味論(2 本のキュー)**:`next-step` = 「次の step 境界を待つ入力」(次の `agent/pre-step` で**ツール結果と同じレベル**の step として同一 turn 内で消費)、`next-turn` = 「独立したターンを待つプロンプト」(現在のターン終了後に**新しい turn** として消費)。`Inbox.claim()` は**必ず `next-step` を先に全部取り**、新ターンを開く境界でのみ `next-turn` を **1 件**追加で取ります。
169
+
170
+ - **session-guard = 止める**:いつ進めるかだけを決め、**キューの内容・順序には触れません**。step ゲート(`agent/pre-step`、次の step のモデルリクエスト前)、ターン級一時停止(`agent.cancel({keepInbox:true})` + `goals.pause`、**キューは保持**)、リクエスト級 hold(`agent/request`)。
171
+ - **input-traffic = 並べる**:ユーザー入力がどのキューに、どの段階で入り、いつ消費されるかだけを決めます(三档:赤=interrupt は `cancel()` 後に `steer`、黄=`steer`(→ `next-step`)、緑=`next-turn` に待機)。凍結 = `queued`+`steering` 行を段階ごと退避 + composer ブロック + `sessionGuard.stopNextTurn`;再開 = ブロック解除 → `sessionGuard.resume` → 段階順に再投入。
172
+ - **接点の 2 つの不変条件**:① 凍結は本プラグインに**先に step ゲートを解放**させる(でないとターン級一時停止が永遠に来ない安全境界を待ち、相互待機になる);② step ゲート保留中は `preStep()` が waterfall 前に `inbox.claim()` 済みなので、新しい入力は取られた分の後ろに並ぶ(`keepInbox` はターン級のみ)。
173
+ - ボタン:本プラグインの「一時停止 / 再開」(order 20)と input-traffic の「❄ 凍結して追加 / 再開して追加」(order 30)は**並列表示・相互に置き換えなし**。
146
174
 
147
175
  ## ライセンス
148
176
 
package/README.ko.md CHANGED
@@ -55,6 +55,7 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
55
55
  | 스위치 | 기본값 | 설명 |
56
56
  |---|---|---|
57
57
  | `enabled` | on | **피크 자동 일시정지**: 피크 시간대에 실행 세션을 자동 일시정지 |
58
+ | `stepLevelPause` | on | **step급 게이트**: 피크에 다음 step의 모델 요청 **전에** 게이트를 닫음(턴급보다 이르고 더 절약). 끄면 턴급 일시정지로 폴백 |
58
59
  | `providerGuard` | on | **공식 소스 2차 판정**: 피크에는 DeepSeek 공식 소스만 차단, 로컬/서드파티 provider는 정상 실행 |
59
60
  | `guardSubagents` | on | **서브에이전트 포함**: 서브에이전트 요청도 과금 대상, 기본 포함 |
60
61
  | `offPeakAutoResume` | on | **오피크 자동 재개**: 오피크에 일시정지 세션을 자동 재개 |
@@ -68,6 +69,7 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
68
69
  - `timezone` (기본값 Asia/Shanghai) — **주말 판정**과 배지 표시에 사용. **피크/오피크 판정에는 영향 없음** (피크는 항상 북경 시간);
69
70
  - `peakWindows` (기본값 09:00–12:00 / 14:00–18:00) — 북경 시간(UTC+8) 기준 피크 윈도우. DeepSeek 공식 과금과 일치;
70
71
  - `pauseMode` (`safe`/`force`), `pauseReason` (`wait`/`stop`);
72
+ - `stepGateTimeoutMs` (기본값 300000) — step 게이트 보류 타임아웃. 만료 시 게이트를 해제하고 **턴급 일시정지로 승격**(교착 방지, "5분마다 1 step" 토큰 누수도 방지);
71
73
  - 공식 소스 판정: `officialProviders`(공식 provider id 추가, 쉼표 구분, 최우선), `officialBaseURLs`(공식 엔드포인트 host, 기본 `api.deepseek.com`);
72
74
  - 연기 큐: `deferredMode`(`hold`/`error`), `deferredResumeText`, `deferredMaxHoldMs`(보류 상한, 기본 6h, 초과 시 error);
73
75
  - 재시도 매개변수: `retryText`, `retryGraceMs`, `retryCooldownMs`, `retryBackoffFactor`, `retryBackoffMaxMs`, `retryMaxConsecutive`.
@@ -76,11 +78,31 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
76
78
 
77
79
  ### 피크 자동 게이트 (글로벌)
78
80
 
79
- - **피크 진입** (그리고 주말 아님): 모든 running 루트 세션에 `gate.stopNextTurn` 호출커스텀 세션 게이트로 진정한 일시정지 (추론 중단 함, 안전 경계에서 일시정지), `queueFallback`으로 락 대기 큐 폴백;
80
- - **오피크 / 주말**: `gate.resume` **모든** 세션 (자동 재개, 수동 불필요) — `offPeakAutoResume` 스위치로 제어;
81
+ - **피크 진입** (그리고 주말 아님): `stepLevelPause`가 켜져 있으면 **턴을 즉시 중단하지 않음** 세션은 다음 `agent/pre-step` 경계까지 진행하고 거기서 step 게이트가 닫힘(아래). 꺼져 있으면 모든 running 루트 세션에 `gate.stopNextTurn` 호출(커스텀 세션 게이트, `queueFallback`으로 락 대기 큐 폴백);
82
+ - **오피크 / 주말**: 먼저 보류 중인 step을 `releaseAll`로 해제(턴은 그 자리에서 계속), 그다음 `gate.resume` **모든** 세션 — `offPeakAutoResume` 스위치로 제어;
81
83
  - **피크 타임존**: 하드코딩된 북경 시간 (`Asia/Shanghai`), DeepSeek 공식 과금 기준과 일치 — `timezone` 설정의 영향을 받지 않음;
82
84
  - 상태 머신: 단일 인스턴스 `NORMAL ↔ PAUSED_PEAK` (`scheduler.js`), 단일 30s tick으로 구동.
83
85
 
86
+ ### step급 게이트 (v0.2.0, 절약의 핵심)
87
+
88
+ `agent/pre-step` waterfall에 연결되어 **다음 step의 모델 요청이 발생하기 전에** 턴을 보류합니다.
89
+
90
+ - **게이트 조건** (모두 충족): `enabled` + `stepLevelPause` + `step > 1` + 피크(북경 시간, 주말 아님) + 대상 provider가 공식(`providerGuard`, 끄면 전부) + 요청급 hold 아님 + 이번 피크 구간에서 수동 스킵 아님;
91
+ - **`step > 1`인 이유**: 턴의 첫 step은 요청급 가드가 담당하므로 두 게이트가 겹치지 않음;
92
+ - **해제 경로**: ① "⏸ 일시정지 중(재개)" 버튼 / `POST /session-guard/rpc {action:'stepResume'}` / `/resume` → 현재 step을 통과시키고 **이번 피크 구간 동안 더 이상 게이트하지 않음**;② 오피크 → 전부 해제, 턴은 그 자리에서 계속(**followup 불필요**);③ 동결 버튼 / `/pause` / `/cancel` → 게이트 해제 후 턴급 일시정지로;④ `signal` abort → 해제;
93
+ - **타임아웃 승격**: `stepGateTimeoutMs`(기본 5분) 초과 시 게이트 해제 + **턴급 force 일시정지로 승격**, 오피크에 복귀(교착 없음·토큰 누수 없음);
94
+ - **상태**: `GET /session-guard/state?session=<id>`가 `paused: { step, turn }`과 `stepGate: { held, since, bypass }` 반환. 서비스 포트 `state().paused`는 **호환을 위해 불리언 유지**, step 상태는 `pausedStep`;
95
+ - **영속화 안 함**: 보류는 프로세스 내 Promise, 재시작 시 소멸(유령 상태 방지).
96
+
97
+ #### "일시정지 / 재개" 버튼 (session-guard 제공)
98
+
99
+ 컴포저 오른쪽의 "일시정지" 버튼 (slot `conversation.input.right`, id `session-guard-pause`, order 20 — input-traffic "❄ 동결 후 추가" 왼쪽):
100
+
101
+ - 일시정지 아님 → "일시정지", **클릭 가능**: `stepPause`를 호출해 **다음 step의 모델 요청 전에** 세션을 정지(현재 step은 중단하지 않음. step 1도 대상이며 피크/provider 제한을 받지 않음);
102
+ - 일시정지 중 → "재개", `stepResume`을 호출해 현재 step을 통과시키고 이번 피크 구간 동안 더 이상 게이트하지 않음;
103
+ - **이벤트 push**: `GET /session-guard/events?session=<id>`(SSE)가 step 게이트 상태 변화를 **즉시** 전달 — 피크에서 자동으로 닫히면 버튼이 바로 "재개"로 바뀝니다. 10초 주기 `/session-guard/state` 폴링은 폴백(SSE 불통이어도 수렴);
104
+ - 같은 줄의 input-traffic 버튼과 모양을 맞췄습니다(높이 24px / 반경 6px / 12px 글꼴 / 동일 CSS 토큰).
105
+
84
106
  ### 공식 소스 판정 (`providerGuard`)
85
107
 
86
108
  피크 시간에 무차별 정지하지 않고, 먼저 "이 요청이 실제로 향하는 라우트가 DeepSeek 공식 소스인가"를 판정합니다.
@@ -134,15 +156,21 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
134
156
  | `off-peak` | 谷时 | `sg-off` | 오피크 시간대, 세션 정상 실행 |
135
157
  | `weekend` | 週末 | `sg-weekend` | 주말 (주말 모드 활성화 시), 피크/오피크 무시 |
136
158
 
137
- - 15초마다 `GET /session-guard/status` 폴링(`phase` / `providerGuard` / `held` / `deferred`);
159
+ - 15초마다 `GET /session-guard/status` 폴링(`phase` / `providerGuard` / `held` / `deferred` / `stepHeld`);
138
160
  - fail-open: 라우트 도달 불가·네트워크 오류·`enabled` OFF → 배지 숨김;
139
161
  - **input-traffic에 의존하지 않음**: session-guard 클라이언트 코드가 단독으로 렌더링. input-traffic는 동결 버튼만 담당;
140
162
 
141
163
  ### input-traffic와의 협업
142
164
 
143
- - input-traffic의 **동결 버튼**은 `sessionGuard.stopNextTurn` (RPC, 세션별) 경유 서버사이드에 전달;
144
- - input-traffic **동결 강화만** (큐 동결/해제 + composer 차단), 재시도는 본 플러그인 백엔드가 처리;
145
- - 둘 다 "세션 격리" 시맨틱 공유: input-traffic 동결 큐는 sessionId로 격리, session-guard RPC도 sessionId로 격리.
165
+ - input-traffic의 **"❄ 동결 후 추가" 버튼**은 `sessionGuard.stopNextTurn` (RPC, 세션별) 전달하며 **먼저 step 게이트를 해제**합니다 (그렇지 않으면 턴급 일시정지가 영원히 오지 않는 안전 경계를 기다려 상호 대기가 됨);
166
+ ### input-traffic와의 역할 분담: "멈추는" 쪽과 "줄 세우는"
167
+
168
+ **DSH 큐 의미론(두 개의 큐)**: `next-step` = "다음 step 경계를 기다리는 입력"(다음 `agent/pre-step`에서 **도구 결과와 같은 레벨**의 step으로 같은 turn 안에서 소비), `next-turn` = "독립 턴을 기다리는 프롬프트"(현재 턴 종료 후 **새 turn**으로 소비). `Inbox.claim()`은 **항상 `next-step`을 먼저 전부** 가져가고, 새 턴을 여는 경계에서만 `next-turn`을 **1건** 추가로 가져갑니다.
169
+
170
+ - **session-guard = 멈춤**: 언제 진행 가능한지만 결정하며 **큐 내용·순서는 건드리지 않습니다**. step 게이트(`agent/pre-step`, 다음 step의 모델 요청 전), 턴급 일시정지(`agent.cancel({keepInbox:true})` + `goals.pause`, **큐는 그대로 보존**), 요청급 hold(`agent/request`).
171
+ - **input-traffic = 줄 세움**: 사용자 입력이 어느 큐에, 어떤 단계로 들어가 언제 소비될지만 결정합니다(3단계: 빨강=interrupt는 `cancel()` 후 `steer`, 노랑=`steer`(→ `next-step`), 초록=`next-turn` 대기). 동결 = `queued`+`steering` 행을 단계째로 분리 + composer 차단 + `sessionGuard.stopNextTurn`; 재개 = 차단 해제 → `sessionGuard.resume` → 단계 순 재투입.
172
+ - **접점의 두 불변식**: ① 동결은 본 플러그인이 **먼저 step 게이트를 해제**하게 해야 합니다(아니면 턴급 일시정지가 영원히 오지 않는 안전 경계를 기다려 상호 대기). ② step 게이트 보류 중에는 `preStep()`이 waterfall 전에 `inbox.claim()`을 끝냈으므로 새 입력은 이미 가져간 배치 뒤에 줄을 섭니다(`keepInbox`는 턴급에만 적용).
173
+ - 버튼: 본 플러그인의 "일시정지 / 재개"(order 20)와 input-traffic의 "❄ 동결 후 추가 / 재개 후 추가"(order 30)는 **병렬 표시·상호 대체 없음**.
146
174
 
147
175
  ## 라이선스
148
176
 
package/README.md CHANGED
@@ -84,6 +84,7 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
84
84
  | 开关 | 默认 | 说明 |
85
85
  |---|---|---|
86
86
  | `enabled` | on | **高峰自动暂停冻结会话**:高峰时段自动暂停运行会话 |
87
+ | `stepLevelPause` | on | **step 级门控**:高峰在下一个 step 的模型请求**之前**拉门(比回合级暂停更早、更省);关掉则回退为回合级暂停 |
87
88
  | `providerGuard` | on | **官方源二维判定**:高峰期只拦 DeepSeek 官方源,本地/第三方 provider 照常跑 |
88
89
  | `guardSubagents` | on | **纳入子代理请求**:子代理请求同样计费,默认一并拦截 |
89
90
  | `offPeakAutoResume` | on | **低谷自动恢复**:低峰时段自动恢复被暂停的会话;关掉则退峰不自动恢复(需手动) |
@@ -97,6 +98,7 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
97
98
  - `timezone`(默认 Asia/Shanghai)——**周末判定**和徽标显示用的时区;**不影响峰谷判定**(峰谷固定按北京时间);
98
99
  - `peakWindows`(默认 09:00–12:00 / 14:00–18:00)——按北京时间(UTC+8)的峰谷窗口,与 DeepSeek 官方计费一致;
99
100
  - `pauseMode`(`safe`/`force`)、`pauseReason`(`wait`/`stop`)——暂停推进方式;
101
+ - `stepGateTimeoutMs`(默认 300000)——step 门挂起超时;到期释放门并**升级为回合级暂停**(防死锁,不会形成「每 5 分钟一个 step」的 token 滴漏);
100
102
  - 官方源判定:`officialProviders`(追加官方 provider id,逗号分隔,优先级最高)、`officialBaseURLs`(官方端点 host 名单,默认 `api.deepseek.com`);
101
103
  - 延后队列:`deferredMode`(`hold` 挂起等待 / `error` 报错并延后)、`deferredResumeText`(退峰续跑文案)、`deferredMaxHoldMs`(挂起上限,默认 6 小时,到期转 error);
102
104
  - 重试参数:`retryText`、`retryGraceMs`、`retryCooldownMs`、`retryBackoffFactor`、`retryBackoffMaxMs`、`retryMaxConsecutive`。
@@ -105,11 +107,31 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
105
107
 
106
108
  ### 高峰自动门(全局)
107
109
 
108
- - **入峰**(且非周末):对所有 running root session 调 `gate.stopNextTurn`——自研会话门真暂停(不打断推理,推理完成/工具派发前落在安全边界暂停),或按 `queueFallback` 回退锁等待队列;
109
- - **退峰 / 周末**:`gate.resume` **全部**会话(自动续跑,无需手动)——受 `offPeakAutoResume` 开关控制,关掉则退峰不自动恢复;
110
+ - **入峰**(且非周末):`stepLevelPause` 开启时**不再立即掐断回合**——会话自然跑到下一个 `agent/pre-step` 边界由 step 门拉门(见下节);关掉则对所有 running root session 调 `gate.stopNextTurn`(自研会话门真暂停,或按 `queueFallback` 回退锁等待队列);
111
+ - **退峰 / 周末**:先 `releaseAll` 放行被挂起的 step(回合原地续跑),再 `gate.resume` **全部**会话——受 `offPeakAutoResume` 开关控制,关掉则退峰不自动恢复;
110
112
  - **峰谷时区**:固定使用北京时间(`Asia/Shanghai`),与 DeepSeek 官方计费基准一致,不受 `timezone` 配置影响;
111
113
  - 状态机:单实例 `NORMAL ↔ PAUSED_PEAK`(`scheduler.js`),由单一 30s tick 驱动。
112
114
 
115
+ ### step 级门控(v0.2.0,省 token 的关键)
116
+
117
+ 挂在 `agent/pre-step` waterfall 上:**在下一个 step 的模型请求发生之前**把回合挂起。
118
+
119
+ - **拉门条件**(全部满足):`enabled` + `stepLevelPause` + `step > 1` + 高峰(北京时间,非周末)+ 目标 provider 属官方(`providerGuard`,关闭时全部拦)+ 该会话未被请求级 hold + 本峰内未被手动跳过;
120
+ - **为什么 `step > 1`**:一个回合的第 1 个 step 由请求级守卫覆盖,两道门不重叠;
121
+ - **释放路径**:①「⏸ 暂停中(继续)」按钮 / `POST /session-guard/rpc {action:'stepResume'}` / `/resume` → 放行当前 step,且**本高峰内不再拦该会话**;② 退峰 → 全部放行,回合原地续跑(**不需要 followup**);③ 冻结按钮 / `/pause` / `/cancel` → 释放门并转入回合级暂停;④ `signal` abort(用户取消)→ 释放门;
122
+ - **超时升级**:挂起超过 `stepGateTimeoutMs`(默认 5 分钟)→ 释放门并**升级为回合级 force 暂停**,退峰统一恢复(不会卡死,也不会在高峰形成 token 滴漏);
123
+ - **状态**:`GET /session-guard/state?session=<id>` 返回 `paused: { step, turn }` 与 `stepGate: { held, since, bypass }`;服务端口 `state()` 的 `paused` **仍是布尔**(向后兼容),step 态用 `pausedStep`;
124
+ - **不落盘**:挂起的是进程内 Promise,重启即失效(避免幽灵状态)。
125
+
126
+ #### 暂停会话 / 继续会话按钮(session-guard 提供)
127
+
128
+ 输入区右侧的「暂停会话」按钮(slot `conversation.input.right`,id `session-guard-pause`,order 20,排在 input-traffic「❄ 冻结追加」左侧):
129
+
130
+ - 未暂停 → 「暂停会话」,**可点**:点击调 `stepPause`,在**下一次 step 的模型请求之前**暂停该会话(不打断当前 step;step 1 也拦,不受峰谷 / provider 限制);
131
+ - 已暂停 → 「继续会话」,点击调 `stepResume`:放行当前 step,且本高峰内不再拦该会话;
132
+ - **事件推送**:`GET /session-guard/events?session=<id>`(SSE)在 step 门状态变化时**即时**推送——高峰期自动拉门后按钮立刻变「继续会话」,无需等轮询;另每 10 秒轮询 `/session-guard/state` 兜底(SSE 不可用 / 断线时仍能收敛);
133
+ - 样式与同一行的 input-traffic 按钮对齐(24px 高 / 6px 圆角 / 12px 字号 / 同一套 CSS 令牌),悬停与暂停态都有对应视觉反馈。
134
+
113
135
  ### 会话锁定(冻结)
114
136
 
115
137
  - **冗余端口**:`ctx.provide('sessionGuard', service)`——`stopNextTurn(sessionId)` / `resume(sessionId)` / `lockQueue(sessionId)` / `unlockQueue(sessionId)` / `state(sessionId)`;
@@ -136,10 +158,10 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
136
158
  | `off-peak` | 谷时 | `sg-off` | 非高峰时段,会话正常运行 |
137
159
  | `weekend` | 周末 | `sg-weekend` | 周末(周末模式开启时),无视峰谷畅快跑 |
138
160
 
139
- - **轮询**:每 15 秒请求 `GET /session-guard/status`,获取全局 `phase`、`providerGuard`、`held`、`deferred`;
161
+ - **轮询**:每 15 秒请求 `GET /session-guard/status`,获取全局 `phase`、`providerGuard`、`held`、`deferred`、`stepHeld`;
140
162
  - **fail-open**:路由不可达、网络错误、或 `enabled` 关闭时→ 徽标静默隐藏,不影响任何会话;
141
163
  - **独立于 input-traffic**:徽标由 session-guard 客户端独立渲染,**不需要安装 input-traffic 插件**即可显示。input-traffic 只负责冻结按钮,与徽标无依赖关系;
142
- - **tooltip**:悬停显示 `阶段 · 时区 · 周末模式 · 判定口径 · 挂起/延后数量`。
164
+ - **tooltip**:悬停显示 `阶段 · 时区 · 周末模式 · 判定口径 · 挂起/延后/step 挂起数量`。
143
165
 
144
166
  ### 官方源判定口径(providerGuard)
145
167
 
@@ -183,11 +205,47 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
183
205
  - 峰谷窗口为**左闭右开** `[start, end)`,支持跨午夜窗口(如 `22:00–06:00`);
184
206
  - `timezone` 配置项对所有语言(中/英/日/韩)通用——`Intl.DateTimeFormat` 的 IANA 时区名不依赖 locale,日文/韩文界面下时区行为与中文完全一致。
185
207
 
186
- ### 与 input-traffic 协作
208
+ ### 与 input-traffic 的分工:一个「停」,一个「排」
209
+
210
+ 两者作用在**同一条链**的不同环节,边界由 DSH 自身的 inbox 模型决定:
211
+
212
+ ```
213
+ 用户输入 ──(input-traffic 定档)──▶ next-step / next-turn 两条待处理队列
214
+
215
+ agent/pre-step ──(本插件 step 门)──▶ 放行 / 挂起
216
+
217
+ agent/request ──(本插件请求级 hold)──▶ 放行 / 挂起
218
+
219
+ 模型调用
220
+ ```
221
+
222
+ **DSH 的队列语义(两条队列,别记混)**
223
+
224
+ | 队列 | 含义 | 消费时机 |
225
+ |---|---|---|
226
+ | `next-step` | 「等下一个 step 边界的输入」 | 下一个 `agent/pre-step`:与**工具返回同级**,在同一次 turn 里再走一个 step |
227
+ | `next-turn` | 「等待独立回合的提示」 | 当前回合结束后,作为**新的 turn** 开跑 |
228
+
229
+ `Inbox.claim()` **永远先取光 `next-step`**,只有该次边界要开新回合时再额外取 **1 条** `next-turn`;一个 turn 的第 1 个 step 取 next-turn,之后都取 next-step。
230
+
231
+ **职责划分**
232
+
233
+ - **session-guard = 停**:只决定「何时可以推进」,**不碰队列内容与顺序**。
234
+ - step 门(`agent/pre-step`):在下一个 step 的模型请求**之前**挂起;
235
+ - 回合级暂停(`agent.cancel({keepInbox:true})` + `goals.pause` + 安全边界):停掉当前回合,**队列原样保留**;
236
+ - 请求级守卫(`agent/request` hold):挂起**这一次模型请求**。
237
+ - **input-traffic = 排**:只决定「用户输入进哪条队列、什么档位、何时被消费」。
238
+ - 三档 = 往哪条队列放:红「打断」先 `cancel()` 再 `steer`;黄「插话」`steer`(→ `next-step`,同 turn 的下一步);绿「排队」留在 `next-turn`;
239
+ - 冻结 = 把 `queued` + `steering` 行整体摘出(保留档位)+ composer block + 调 `sessionGuard.stopNextTurn`;恢复 = 清 block → 先 `sessionGuard.resume` → 按档位重投。
240
+
241
+ **相遇点上的两条铁律**
242
+
243
+ 1. **冻结必须让本插件先释放 step 门**:step 门挂在 `agent/pre-step`,而回合级暂停在等安全边界事件——两者互等(本插件 `pauseTask` / `cancelTask` 已先 `release`);
244
+ 2. **step 门挂起时消息已被取走**:`preStep()` 先 `inbox.claim()` 再派发 waterfall,所以挂起期间新输入排在被取走的那批之后;`keepInbox` 只作用于回合级暂停。
245
+
246
+ **不会互相越界**:input-traffic 不监听 `agent/pre-step` / `agent/request`(唯一例外是「打断」档显式 `cancel()`,那是用户主动要求打断);本插件也不改写 `next-step` / `next-turn` 的内容与顺序。
187
247
 
188
- - input-traffic 的**冻结按钮**触发时经 `sessionGuard.stopNextTurn`(RPC,会话级)透传服务端;
189
- - input-traffic **只做冻结增强**(队列冻结/解冻 + composer block),重试归本插件后端;
190
- - 两者共享「会话隔离」语义:input-traffic 冻结队列按 sessionId 隔离,session-guard RPC 同样按 sessionId 锁。
248
+ 按钮上:本插件的「暂停会话 / 继续会话」(order 20)与 input-traffic 的「❄ 冻结追加 / 恢复追加」(order 30)并列显示、互不取代——前者控 step 门,后者控队列摘除 + 回合级冻结。
191
249
 
192
250
  ## 冗余端口 `sessionGuard`
193
251
 
@@ -197,18 +255,21 @@ dsh plugin --profile web add github:<owner>/dsh-session-guard
197
255
  resume(sessionId, opts), // 恢复(confirm + choice: rerun|skip)
198
256
  lockQueue(sessionId, reason), // 显式锁队列
199
257
  unlockQueue(sessionId), // 显式解锁
200
- state(sessionId), // { queueLocked, lockReason, paused, taskControlAvailable, taskControl }
258
+ stepPause(sessionId), // 手动请求 step 级暂停(下一次 pre-step 边界拉门,step 1 也拦)
259
+ stepResume(sessionId, opts), // 解开 step 门(v0.2.0);opts.bypass=false 时不置本峰跳过
260
+ state(sessionId), // { queueLocked, lockReason, paused, pausedStep, stepHeldSince, stepBypass, taskControlAvailable, taskControl }
201
261
  }
202
262
  ```
203
263
 
204
264
  ## HTTP 路由
205
265
 
206
- - `GET /session-guard/state?session=<id>` — 会话状态(含最近目标 / 是否挂起 / 是否延后)
266
+ - `GET /session-guard/state?session=<id>` — 会话状态(含 `paused: { step, turn, manual }` / `stepGate` / 最近目标 / 是否挂起 / 是否延后)
267
+ - `GET /session-guard/events?session=<id>` — **SSE**:step 门状态变化即时推送(按钮据此更新)
207
268
  - `GET /session-guard/settings` — 设置 + taskControl 可用性
208
- - `GET /session-guard/status` — 全局当前阶段(状态徽标轮询)
269
+ - `GET /session-guard/status` — 全局当前阶段(状态徽标轮询;含 `stepHeld`)
209
270
  - `GET /session-guard/provider?provider=<id>` — 官方源判定诊断(`official` / `matchedBy` / `endpoint`)
210
- - `GET /session-guard/diag` — 运行时诊断
211
- - `POST /session-guard/rpc` — `{ action: stopNextTurn|resume|lockQueue|unlockQueue|state, sessionId }`
271
+ - `GET /session-guard/diag` — 运行时诊断(含 `stepGate`)
272
+ - `POST /session-guard/rpc` — `{ action: stopNextTurn|resume|lockQueue|unlockQueue|stepPause|stepResume|state, sessionId }`
212
273
 
213
274
  ## 状态存储
214
275
 
@@ -230,9 +291,10 @@ npm test # node --test tests/*.test.mjs(时区/周末/状态机/会话门/
230
291
  | `src/provider-directory.js` | 端点目录(`llm.listConfigurableProviders` + `settings.get`,全链路降级) |
231
292
  | `src/deferrals.js` | 延后登记表(hold 挂起 / 释放 / 超限 / `PeakDeferredError`) |
232
293
  | `src/request-guard.js` | `agent/request` 请求级守卫(hold / error 两模式) |
294
+ | `src/step-gate.js` | **`agent/pre-step` step 级门控**(v0.2.0:拉门 / 释放 / 超时升级 / bypass,纯判定 `decideStepHold` 可单测) |
233
295
  | `src/targets.js` | 会话「最近真实目标」追踪(`request/header` + `model/selection`) |
234
- | `src/wiring.js` | 接线编排(入峰过滤 / 退峰释放 / 精确定时 / 卸载清理) |
235
- | `src/pause-gate.js` | 自研会话门引擎(agent.cancel keepInbox + goals.pause + 安全边界 + followup 续跑) |
296
+ | `src/wiring.js` | 接线编排(入峰过滤 / step 门接线 / 退峰释放 / 精确定时 / 卸载清理) |
297
+ | `src/pause-gate.js` | 自研会话门引擎(agent.cancel keepInbox + goals.pause + 安全边界 + followup 续跑;暂停前先释放 step 门) |
236
298
  | `src/pause-store.js` | 自研暂停状态持久化 |
237
299
  | `src/gate.js` | 会话门驱动(自研真暂停 / 回退锁队列,fail-open) |
238
300
  | `src/bridge.js` | `sessionGuard` 冗余端口 |
@@ -241,7 +303,7 @@ npm test # node --test tests/*.test.mjs(时区/周末/状态机/会话门/
241
303
  | `src/store.js` | 每会话持久化状态 |
242
304
  | `src/settings.js` | 设置子板块(schemastery schema + fail-open 注册) |
243
305
  | `src/index.js` | host apply(设置/路由/tick/提供服务/重试接线/请求守卫) |
244
- | `src/client/` | 浏览器 half(状态徽标 + 设置卡片 + 四语言字典) |
306
+ | `src/client/` | 浏览器 half(**暂停会话按钮** + 状态徽标 + 设置卡片) |
245
307
 
246
308
  ## License
247
309