dsh-llm-fallbacks 0.4.3 → 0.4.4

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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  ![node](https://img.shields.io/badge/node-%3E%3D22-339933.svg)
8
8
  ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D10-f69220.svg)
9
9
  ![dsh tui](https://img.shields.io/badge/dsh%20tui-compatible-4B32C3.svg)
10
- ![dsh](https://img.shields.io/badge/DSH-0.1.2--rc.1-4B32C3.svg)
10
+ ![dsh](https://img.shields.io/badge/DSH-0.1.5--rc.1-4B32C3.svg)
11
11
  [![dshfind](https://dshfind.com/api/badge/omdsh-dev/dsh-llm-fallbacks?lang=en)](https://dshfind.com/zh/plugins/omdsh-dev/dsh-llm-fallbacks?ref=badge)
12
12
 
13
13
  Automatic provider/model fallback chains for dsh (DeepSeek Harness): when an agent's LLM requests keep failing — retries exhausted, auth errors, quota exceeded, rate limiting (429) — the plugin switches provider/model along the fallback chain for the current role, and the current step/turn continues on the target model: tasks are not interrupted by model problems.
@@ -31,7 +31,7 @@ Four frozen UTC+8 presets (windows are code constants; preset rows lock `tz` to
31
31
 
32
32
  GLM Peak and GLM Valley are offered in the card picker only when `zai-coding-cn` is configured.
33
33
 
34
- The first extra row whose window contains the current moment (in `fallbacks.tz`, default Asia/Shanghai) wins; no match → the all-day `rootChain`, whose tail (Default model) must be exactly one official V4 model — `deepseek-official/deepseek-v4-flash` XOR `deepseek-official/deepseek-v4-pro`. Slot rotation is a routing seed, not a failure decision: it applies on the next root request, consumes no cooldown, and is logged as a time-slot switch — failure walks keep fallback switch. Full semantics → [Time-slot presets](#time-slot-presets) and [docs/configuration.md](docs/configuration.md).
34
+ The first extra row whose window contains the current moment (in `fallbacks.tz`, default Asia/Shanghai) wins; no match → the all-day `rootChain`, whose tail (Default model) must be exactly one official model — `deepseek-official/deepseek-flash` or `deepseek-official/deepseek-pro` (XOR). Slot rotation is a routing seed, not a failure decision: it applies on the next root request, consumes no cooldown, and is logged as a time-slot switch — failure walks keep fallback switch. Full semantics → [Time-slot presets](#time-slot-presets) and [docs/configuration.md](docs/configuration.md).
35
35
 
36
36
  ## Quick start
37
37
 
@@ -46,17 +46,16 @@ Same plugin, either front end — the only difference is the `--profile` flag. P
46
46
 
47
47
  ### Repair existing sessions (versions before 0.2.2)
48
48
 
49
- Versions before 0.2.2 wrote durable `fallbacks/switch` session events that newer dsh releases refuse to load (issue #52 — the apply()-time event-type registration is ineffective because plugin and host resolve different module instances). If existing sessions fail to open after an upgrade, clone this repository and repair the logs (stop dsh first):
49
+ Versions before 0.2.2 wrote durable `fallbacks/switch` session events that newer dsh releases refuse to load (issue #52 — the apply()-time event-type registration is ineffective because plugin and host resolve different module instances). If existing sessions fail to open after an upgrade, clone this repository and run the detector:
50
50
 
51
51
  ```sh
52
52
  git clone https://github.com/omdsh-dev/dsh-llm-fallbacks.git
53
53
  cd dsh-llm-fallbacks
54
54
  pnpm install
55
- pnpm repair:fallbacks-switch-logs -- --dry-run # preview which sessions would change
56
- pnpm repair:fallbacks-switch-logs -- --apply --backup # mark legacy events ignorable
55
+ pnpm repair:fallbacks-switch-logs -- --dry-run
57
56
  ```
58
57
 
59
- The script scans `~/.dsh/sessions` by default (override with `--root <dir>`), marks legacy `fallbacks/switch` events `ignorable: true` so the host read path accepts the session again, and keeps a `<file>.bak` per repaired log. `--apply` requires `--backup` and must run with dsh stopped. From 0.2.2 on, the plugin stops writing durable switch events, so no new sessions need repair.
58
+ The script scans `~/.dsh/sessions` by default (override with `--root <dir>`). **It cannot repair these logs**: the released session-format migration chain (v0→v1) refuses unknown event types even when marked `ignorable: true`, so a "repaired" log would still be rejected on load. The script therefore fails closed — it only reports which sessions contain `fallbacks/switch` events, never writes or backs up any file, and exits non-zero when it finds any (the legacy `--apply` / `--backup` flags are accepted as no-ops). The durable fix belongs upstream at the migration edges. From 0.2.2 on, the plugin stops writing durable switch events, so no new sessions need attention.
60
59
 
61
60
  ### Configuration surfaces
62
61
 
@@ -77,9 +76,9 @@ Add a `fallbacks:` section to the shared settings document (`$DSH_HOME/settings.
77
76
  ```yaml
78
77
  fallbacks:
79
78
  enabled: true # feature switch — defaults to off (plugin is a no-op otherwise)
80
- rootChain: # all-day chain: leading entries = fallback walk, last = Default model (official V4)
79
+ rootChain: # all-day chain: leading entries = fallback walk, last = Default model (official)
81
80
  - anthropic/claude-3-5-sonnet # walked first
82
- - deepseek-official/deepseek-v4-flash # last resort (Flash or Pro)
81
+ - deepseek-official/deepseek-flash # last resort (Flash or Pro)
83
82
  timeSlots: # optional: rotate the effective root chain by wall-clock windows
84
83
  - kind: preset # frozen UTC+8 window; only the chain is editable
85
84
  preset: liang-peak # Monday–Friday 09:00–12:00 and 14:00–18:00
@@ -109,7 +108,7 @@ Build the section up in four steps:
109
108
 
110
109
  **2. Set the all-day `rootChain`.** Leading entries are the fallback chain, walked first when a request fails; the **last** entry is the Default model.
111
110
 
112
- > **Conformance**: the last entry must be exactly one official V4 model — `deepseek-official/deepseek-v4-flash` XOR `deepseek-official/deepseek-v4-pro`. The settings card and gateway reject any other tail on save; a legacy non-official tail warns at startup and keeps working as a fallback-only walk, but cannot be saved as-is.
111
+ > **Conformance**: the last entry must be exactly one official model — `deepseek-official/deepseek-flash` or `deepseek-official/deepseek-pro` (XOR). The settings card and gateway reject any other tail on save; a legacy non-official tail warns at startup and keeps working as a fallback-only walk, but cannot be saved as-is. The retired `deepseek-v4-flash` / `deepseek-v4-pro` ids are no longer legal tails — a saved V4 tail now warns, goes inert (slot rows + virtual picker), and blocks save until a legal tail is picked. `deepseek-pro` is a legal selector whose model is not yet served by the catalog: the card shows it disabled ("not yet available"), and requests to it fail at the provider until the gateway enables the id. The plugin does not probe catalog availability — a chain containing `deepseek-pro` dispatches to it like any other exact entry, and the override resolves the first exact head of the effective chain, so a chain with a working entry before Pro still routes to that earlier entry.
113
112
 
114
113
  **3. Add `timeSlots` (optional).** Rows rotate the effective root chain by wall-clock windows. Preset rows use frozen UTC+8 windows (only their chain is editable; while a preset row exists, `tz` locks to `Asia/Shanghai`); custom rows take `start`/`end` (may wrap midnight) and an optional `days` list. The first row whose window contains the current moment wins; no match → the all-day `rootChain`. Rotation is a routing seed — it applies on the next root request and consumes no cooldown (see [Time slots](#time-slots)).
115
114
 
@@ -135,7 +134,7 @@ Save the config and restart the session, then type `/fallbacks` — the read-onl
135
134
  - **Cooldown and revert**: failed / switched-away models are not re-selected during cooldown; `revertPolicy: cooldown-expiry` returns to the primary model automatically.
136
135
  - **Host subagent model policy (dsh 0.1.2)**: when the host `subagent-model-selection` policy is enabled, its allowlist is a hard constraint on every plugin-originated subagent route — an explicit authorized spawn route stays the chain head (role-inject skipped), inheritance inject heads and failure-switch targets are intersected with the effective allowlist, and an empty intersection skips the inject/switch (warn log + read-only card warning; no out-of-allowlist request is ever sent). A present-but-unreadable policy fails closed. Policy off/absent → inject and failure-switch selection exactly as 0.3.5. Override `reasoningEffort` follows the upstream routeChanged rule on every path (same route → keep; route change → drop unless explicit). See [Host subagent model policy](#host-subagent-model-policy-dsh-012).
137
136
  - **Half-open recovery (opt-in)**: `recovery: half-open` makes recovery evidence-driven — an expired cooldown leaves the route half-open for one logged probe instead of restoring the preference; consecutive failures escalate the suppression duration (×2 per failure, capped at 1 h); an observed completion closes the circuit and fully restores the preference. `revertPolicy: 'never'` keeps the mechanism inert; state is session-scoped in-memory (a restart resets). YAML-only — the default `timer` keeps every existing behavior byte-identical (see [docs/configuration.md](docs/configuration.md#recovery-mode-recovery-key)).
138
- - **Visible behavior**: every switch is recorded in an info-level log line (from/to/role/reason) — no silent model switching. The plugin deliberately writes **no** durable `fallbacks/switch` session events (issue #52: the apply()-time event-type registration was proven ineffective, and a session containing the event refused to load after a dsh restart). Sessions written by older plugin versions that contain such events are repaired by `scripts/repair-fallbacks-switch-logs.ts`, which marks legacy events ignorable so affected sessions load again.
137
+ - **Visible behavior**: every switch is recorded in an info-level log line (from/to/role/reason) — no silent model switching. The plugin deliberately writes **no** durable `fallbacks/switch` session events (issue #52: the apply()-time event-type registration was proven ineffective, and a session containing the event refused to load after a dsh restart). Sessions written by older plugin versions that contain such events **cannot** be repaired by an `ignorable` flag — the released session-format migration chain (v0→v1) refuses unknown event types even when marked ignorable — so `scripts/repair-fallbacks-switch-logs.ts` now fails closed and only reports such logs (the durable fix belongs upstream at the migration edges).
139
138
  - **Safety valves**: `maxSwitchesPerStep` caps switches per step and `alwaysModeRetryCap` caps always-mode retries — chain loops cannot amplify latency.
140
139
  - **No-config no-op**: with no chains configured the plugin behaves exactly like not being installed (`enabled` is off by default — see [Minimal configuration](#minimal-configuration)).
141
140
 
@@ -161,9 +160,9 @@ There is **no `rootMode` switch** — no config key, YAML field, settings toggle
161
160
 
162
161
  Notes:
163
162
 
164
- - **Picker label**: the row's catalog `name` (what the composer trigger shows) is live — `Auto: DeepSeek V4 Flash[Liang Peak]` / `Auto: DeepSeek V4 Flash[all-day]` (catalog display name, not the model id); the id stays `Auto`. Bare `Auto` if the all-day tail is not conforming. Refresh by reopening the picker.
163
+ - **Picker label**: the row's catalog `name` (what the composer trigger shows) is live — `Auto: DeepSeek Flash[Liang Peak]` / `Auto: DeepSeek Flash[all-day]` (catalog display name, not the model id); the id stays `Auto`. Bare `Auto` if the all-day tail is not conforming. Refresh by reopening the picker.
165
164
  - **Root only**: the row is about the root agent. Subagent role resolution and injection are unchanged; a subagent session that inherits the selection still routes through the chain head — the virtual row is a thin delegate, never a second routing engine.
166
- - **Conformance gate on the tail**: a successful override/delegate requires the all-day chain to be **tail-conforming** — its last entry must be exactly one official V4 model (`deepseek-official/deepseek-v4-flash` or `deepseek-official/deepseek-v4-pro`, the card's Default model panel); leading entries (Default fallback chain) are walked first. Disabling the plugin hides the row again (slot-row/chain edits never churn registration).
165
+ - **Conformance gate on the tail**: a successful override/delegate requires the all-day chain to be **tail-conforming** — its last entry must be exactly one official model (`deepseek-official/deepseek-flash` or `deepseek-official/deepseek-pro`, the card's Default model panel); leading entries (Default fallback chain) are walked first. Disabling the plugin hides the row again (slot-row/chain edits never churn registration).
167
166
  - **Stale selection**: if the row disappears (plugin disabled) while `FallbacksChain / Auto` is selected, the session keeps showing it as the current model with `routable: false` — pick a real model from the catalog to continue (host-native catalog semantics).
168
167
  - **Capabilities follow the head**: the row's model metadata (context window, modalities, reasoning) mirrors the current effective head; retry attribution follows the permissive default — retries/failures are accounted to the real head pair, not to the `FallbacksChain` provider. Full semantics → [docs/configuration.md](docs/configuration.md).
169
168
 
@@ -171,11 +170,11 @@ Notes:
171
170
 
172
171
  Time slots are introduced in the [featured overview](#time-slots) above; this section is the reference. Time-slot rows rotate the **effective root chain** by wall-clock windows — useful for peak/valley pricing without confusing wall-clock rotation with failure fallback. The copy split is strict: slot rotation logs and UI say **time-slot switch**; the failure walk keeps **fallback switch**; the conversation notice Model downgraded stays on the failure path only.
173
172
 
174
- - **Match order**: at every root request, the first extra row whose window contains the current moment (in `fallbacks.tz`, default `Asia/Shanghai` / UTC+8) wins — that row's chain **replaces** the all-day chain. No row matches → the all-day `rootChain` is used. The all-day row is always last and **required**: its last entry must be exactly one official V4 model (Flash XOR Pro; leading Default fallback chain entries are walked first).
173
+ - **Match order**: at every root request, the first extra row whose window contains the current moment (in `fallbacks.tz`, default `Asia/Shanghai` / UTC+8) wins — that row's chain **replaces** the all-day chain. No row matches → the all-day `rootChain` is used. The all-day row is always last and **required**: its last entry must be exactly one official model (Flash / Pro XOR; leading Default fallback chain entries are walked first).
175
174
  - **Presets** (frozen, not user-editable): `liang-peak` = Monday–Friday 09:00–12:00 **and** 14:00–18:00; `liang-valley` = every other UTC+8 time; `glm-peak` = Monday–Friday 14:00–18:00; `glm-valley` = every other time. One preset id = one row; the card picker never offers a duplicate.
176
175
  - **Custom rows**: `start` / `end` (`HH:mm`, may wrap midnight) + optional `days` (0=Sunday…6=Saturday; omitted/empty = every day) + models.
177
176
  - **Next-request apply**: a slot boundary crossing never preempts an in-flight step — the new row takes effect on the next root request. Rotation is mount-only: info log + card/`/fallbacks` status line, no durable switch event.
178
- - **Settings card**: the Main agent section groups Time slots (extra rows — add preset / add custom / remove / reorder by buttons or **drag**; preset rows show a read-only window summary and edit models only; custom rows carry an editable name; the **timezone picker** lives here and **locks to Asia/Shanghai while any preset row exists**, since preset windows are frozen UTC+8 constants), Default fallback chain (walked first when no slot matches) and Default model (the official V4 Flash | Pro last-resort fallback). Rows are collapsible to name + first model. There is no `timeSlots.enabled` master switch (adding a row is the opt-in) and no `rootMode` control.
177
+ - **Settings card**: the Main agent section groups Time slots (extra rows — add preset / add custom / remove / reorder by buttons or **drag**; preset rows show a read-only window summary and edit models only; custom rows carry an editable name; the **timezone picker** lives here and **locks to Asia/Shanghai while any preset row exists**, since preset windows are frozen UTC+8 constants), Default fallback chain (walked first when no slot matches) and Default model (the official Flash | Pro last-resort fallback). Rows are collapsible to name + first model. There is no `timeSlots.enabled` master switch (adding a row is the opt-in) and no `rootMode` control.
179
178
 
180
179
  ## Preset roles
181
180
 
@@ -202,7 +201,7 @@ fallbacks:
202
201
  - CONTEXT_WINDOW_EXCEEDED # fail over when the request does not fit
203
202
  rootChain:
204
203
  - anthropic/claude-3-5-sonnet # walked first
205
- - deepseek-official/deepseek-v4-flash # last resort (Flash or Pro)
204
+ - deepseek-official/deepseek-flash # last resort (Flash or Pro)
206
205
  ```
207
206
 
208
207
  **Ordering — the fallback runs before compaction.** With `CONTEXT_WINDOW_EXCEEDED` in `triggerCodes`, this plugin's `agent/request-error` listener handles the rejection **before** the harness's compaction plugin gets to compact: the conversation moves to the fallback model on the first overflow, and the context is never compacted. Leave the code out of `triggerCodes` if you would rather have compaction tried first.
package/README.zh-CN.md CHANGED
@@ -7,7 +7,7 @@
7
7
  ![node](https://img.shields.io/badge/node-%3E%3D22-339933.svg)
8
8
  ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D10-f69220.svg)
9
9
  ![dsh tui](https://img.shields.io/badge/dsh%20tui-compatible-4B32C3.svg)
10
- ![dsh](https://img.shields.io/badge/DSH-0.1.2--rc.1-4B32C3.svg)
10
+ ![dsh](https://img.shields.io/badge/DSH-0.1.5--rc.1-4B32C3.svg)
11
11
  [![dshfind](https://dshfind.com/api/badge/omdsh-dev/dsh-llm-fallbacks?lang=zh)](https://dshfind.com/zh/plugins/omdsh-dev/dsh-llm-fallbacks?ref=badge)
12
12
 
13
13
  dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subagent 的模型请求持续失败(重试耗尽、权限、配额超限、限流 429)时,按角色/模型 fallback 链自动切换 provider/model,当前 step/turn 在目标模型上继续完成——任务不因模型问题中断。
@@ -31,7 +31,7 @@ dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subage
31
31
 
32
32
  GLM 峰与 GLM 谷仅在已配置 `zai-coding-cn` 时出现在设置卡选择器中。
33
33
 
34
- 每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 Asia/Shanghai)的额外行生效;无行命中 → 全时段 `rootChain`——其链尾(默认模型)必须是恰好一个官方 V4 模型:`deepseek-official/deepseek-v4-flash` 或 `deepseek-official/deepseek-v4-pro`(二选一)。分时切换是路由种子而非失败决策:在下一个 root 请求生效、不消耗冷却、不计入 `maxSwitchesPerStep`,日志记为**分时切换**;失败降级保持**降级切换**。完整语义 → [分时槽预设(分时切换)](#分时槽预设分时切换) 与 [docs/configuration.md](docs/configuration.md)。
34
+ 每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 Asia/Shanghai)的额外行生效;无行命中 → 全时段 `rootChain`——其链尾(默认模型)必须是恰好一个官方模型:`deepseek-official/deepseek-flash` 或 `deepseek-official/deepseek-pro`(二选一)。分时切换是路由种子而非失败决策:在下一个 root 请求生效、不消耗冷却、不计入 `maxSwitchesPerStep`,日志记为**分时切换**;失败降级保持**降级切换**。完整语义 → [分时槽预设(分时切换)](#分时槽预设分时切换) 与 [docs/configuration.md](docs/configuration.md)。
35
35
 
36
36
  ## 快速开始
37
37
 
@@ -46,17 +46,16 @@ dsh plugin --profile dsh-tui add dsh-llm-fallbacks # dsh-tui 终端 profile
46
46
 
47
47
  ### 修复旧会话(0.2.2 之前的版本)
48
48
 
49
- 0.2.2 之前的版本会把 `fallbacks/switch` 事件写入会话持久化日志,而新版 dsh 拒绝加载这类会话(issue #52——apply() 时的注册因插件与宿主解析到不同模块实例而无效)。如果升级后已有会话打不开,clone 本仓库并修复日志(先停 dsh):
49
+ 0.2.2 之前的版本会把 `fallbacks/switch` 事件写入会话持久化日志,而新版 dsh 拒绝加载这类会话(issue #52——apply() 时的注册因插件与宿主解析到不同模块实例而无效)。如果升级后已有会话打不开,clone 本仓库并运行检测器:
50
50
 
51
51
  ```sh
52
52
  git clone https://github.com/omdsh-dev/dsh-llm-fallbacks.git
53
53
  cd dsh-llm-fallbacks
54
54
  pnpm install
55
- pnpm repair:fallbacks-switch-logs -- --dry-run # 预览哪些会话会被改动
56
- pnpm repair:fallbacks-switch-logs -- --apply --backup # 给旧事件打 ignorable 标记
55
+ pnpm repair:fallbacks-switch-logs -- --dry-run
57
56
  ```
58
57
 
59
- 脚本默认扫描 `~/.dsh/sessions`(可用 `--root <dir>` 覆盖),把遗留 `fallbacks/switch` 事件标记为 `ignorable: true`,宿主读路径即可重新接受该会话;每个被修复的日志保留一份 `<file>.bak`。`--apply` 必须搭配 `--backup`,且须在 dsh 停止时运行。从 0.2.2 起插件不再写 durable 切换事件,新会话无需修复。
58
+ 脚本默认扫描 `~/.dsh/sessions`(可用 `--root <dir>` 覆盖)。**它无法修复这些日志**:已发布的 session-format 迁移链(v0→v1)即使事件带 `ignorable: true` 也拒绝未知事件类型,因此「修复后」的日志仍会被拒绝加载。脚本因此 fail-closed——只报告哪些会话含 `fallbacks/switch` 事件,绝不写入或备份任何文件,发现即非零退出(遗留的 `--apply` / `--backup` 参数按 no-op 接受)。持久修复应在上游迁移边界完成。从 0.2.2 起插件不再写 durable 切换事件,新会话无需处理。
60
59
 
61
60
  ### 配置界面
62
61
 
@@ -77,9 +76,9 @@ pnpm repair:fallbacks-switch-logs -- --apply --backup # 给旧事件打 igno
77
76
  ```yaml
78
77
  fallbacks:
79
78
  enabled: true # 功能开关——默认关闭(否则插件完全 no-op)
80
- rootChain: # 全时段链:前面的条目 = 降级路径,最后一项 = 默认模型(官方 V4)
79
+ rootChain: # 全时段链:前面的条目 = 降级路径,最后一项 = 默认模型(官方模型)
81
80
  - anthropic/claude-3-5-sonnet # 先走
82
- - deepseek-official/deepseek-v4-flash # 最后一档(Flash 或 Pro)
81
+ - deepseek-official/deepseek-flash # 最后一档(Flash 或 Pro)
83
82
  timeSlots: # 可选:按墙钟窗口轮换生效 root 链
84
83
  - kind: preset # 冻结的 UTC+8 窗口;仅链可编辑
85
84
  preset: liang-peak # 周一至周五 09:00–12:00 与 14:00–18:00
@@ -109,7 +108,7 @@ fallbacks:
109
108
 
110
109
  **2. 配置全时段 `rootChain`。** 前面的条目是降级链,请求失败时先走;**最后**一项是默认模型。
111
110
 
112
- > **链尾合规**:最后一项必须是恰好一个官方 V4 模型——`deepseek-official/deepseek-v4-flash` 或 `deepseek-official/deepseek-v4-pro`(二选一)。设置卡与 gateway 在保存时拒绝其它尾巴;遗留的非合规尾巴启动时告警并继续按 fallback-only 走原链,但无法原样保存。
111
+ > **链尾合规**:最后一项必须是恰好一个官方模型——`deepseek-official/deepseek-flash` 或 `deepseek-official/deepseek-pro`(二选一)。设置卡与 gateway 在保存时拒绝其它尾巴;遗留的非合规尾巴启动时告警并继续按 fallback-only 走原链,但无法原样保存。已退役的 `deepseek-v4-flash` / `deepseek-v4-pro` 不再是合法链尾——已保存的 V4 尾巴现在会告警、进入惰性(分时行 + 虚拟选择器)、并在选到合法链尾前阻止保存。`deepseek-pro` 是合法选择器但其模型尚未进入目录:设置卡中显示为禁用(「暂不可用」),请求在 gateway 启用该 id 前会在 provider 处失败。插件不探测目录可用性——含 `deepseek-pro` 的链会像其它精确条目一样派发到它;覆盖解析生效链的第一个精确链头,因此 Pro 之前有可用条目的链仍路由到更早的条目。
113
112
 
114
113
  **3. 添加 `timeSlots`(可选)。** 各行按墙钟窗口轮换生效 root 链。预设行使用冻结的 UTC+8 窗口(仅链可编辑;存在预设行时 `tz` 锁定 `Asia/Shanghai`);自定义行使用 `start`/`end`(可跨午夜)与可选的 `days` 列表。第一个窗口包含当前时刻的行生效;无行命中 → 全时段 `rootChain`。分时切换是路由种子——在下一个 root 请求生效、不消耗冷却(见 [峰谷无忧](#峰谷无忧))。
115
114
 
@@ -135,7 +134,7 @@ fallbacks:
135
134
  - **冷却与回主**:被切离/失败的模型在冷却期内不再入选;`revertPolicy: cooldown-expiry` 冷却到期后自动回主模型。
136
135
  - **宿主子代理模型策略(dsh 0.1.2)**:当宿主 `subagent-model-selection` 策略启用时,其允许列表对每个插件发起的 subagent 路由都是硬约束——显式授权的派发路由保持为链头(跳过角色注入),继承注入的链头与失败切换目标都与生效允许列表求交集,交集为空则跳过注入/切换(warn 日志 + 只读卡片警告;绝不发送允许列表之外的请求)。策略存在但不可读时 fail-closed。策略关闭/缺省时,注入与失败切换的选择与 0.3.5 完全一致。覆盖路径上的 `reasoningEffort` 遵循上游 routeChanged 规则(同路由 → 保留;跨路由 → 除非显式指定否则丢弃)。见 [宿主子代理模型策略](#宿主子代理模型策略dsh-012)。
137
136
  - **半开恢复(可选)**:`recovery: half-open` 让恢复以证据驱动——冷却到期后路由进入 **half-open**,以一次记录探针(logged probe)放行,而不是直接恢复首选;连续失败使抑制时长按 **×2** 逐次升级、**1 小时**封顶;观察到完成即闭合回路、完全恢复首选。`revertPolicy: 'never'` 使该机制完全失效;状态为会话级内存态(重启即重置)。仅 YAML 配置——默认 `timer` 保持所有既有行为逐字节一致(见 [docs/configuration.md](docs/configuration.md#recovery-mode-recovery-key))。
138
- - **行为可见**:每次切换以 info 级日志行(from/to/role/reason)记录——无静默换模型。插件**刻意不写** durable `fallbacks/switch` 会话事件(issue #52——apply() 时的事件类型注册被证伪无效,含该事件的会话在 dsh 重启后拒绝加载)。由旧版插件写入、含此类事件的会话由 `scripts/repair-fallbacks-switch-logs.ts` 修复——旧事件被标记 ignorable 后,受影响会话可重新加载。
137
+ - **行为可见**:每次切换以 info 级日志行(from/to/role/reason)记录——无静默换模型。插件**刻意不写** durable `fallbacks/switch` 会话事件(issue #52——apply() 时的事件类型注册被证伪无效,含该事件的会话在 dsh 重启后拒绝加载)。由旧版插件写入、含此类事件的会话**无法**通过 `ignorable` 标记修复——已发布的 session-format 迁移链(v0→v1)即使事件带 `ignorable` 也拒绝未知事件类型——因此 `scripts/repair-fallbacks-switch-logs.ts` 现在 fail-closed,只报告此类日志(持久修复应在上游迁移边界完成)。
139
138
  - **安全阀**:`maxSwitchesPerStep` 限制每 step 切换次数、`alwaysModeRetryCap` 限制 always 模式重试——链循环不会放大延迟。
140
139
  - **无配置回归(no-op)**:未配置任何链时行为与未安装插件完全一致——`enabled` 默认关闭(见 [最小配置](#最小配置))。
141
140
 
@@ -161,9 +160,9 @@ fallbacks:
161
160
 
162
161
  注意:
163
162
 
164
- - **选择器文案**:目录行的 `name`(composer 触发器显示)是动态的——`Auto: DeepSeek V4 Flash[Liang Peak]` / `Auto: DeepSeek V4 Flash[all-day]`(用 catalog 显示名,不是 model id);id 仍是 `Auto`。all-day 尾巴不合规则只显示 `Auto`。重新打开选择器即可刷新。
163
+ - **选择器文案**:目录行的 `name`(composer 触发器显示)是动态的——`Auto: DeepSeek Flash[Liang Peak]` / `Auto: DeepSeek Flash[all-day]`(用 catalog 显示名,不是 model id);id 仍是 `Auto`。all-day 尾巴不合规则只显示 `Auto`。重新打开选择器即可刷新。
165
164
  - **仅 root**:这一行只关乎 root 代理。subagent 的角色解析与注入不变;继承了该选择的 subagent 会话仍经链头路由——虚拟行只是薄委托,绝不是第二个路由引擎。
166
- - **链尾合规门槛**:覆盖/委托成功要求 all-day 链**尾巴合规**——最后一项必须是恰好一个官方 V4 模型(`deepseek-official/deepseek-v4-flash` 或 `deepseek-official/deepseek-v4-pro`,即设置卡的「默认模型」面板);前面的默认降级链先走。禁用插件后该行隐藏(slot/链编辑不会触发注册抖动)。
165
+ - **链尾合规门槛**:覆盖/委托成功要求 all-day 链**尾巴合规**——最后一项必须是恰好一个官方模型(`deepseek-official/deepseek-flash` 或 `deepseek-official/deepseek-pro`,即设置卡的「默认模型」面板);前面的默认降级链先走。禁用插件后该行隐藏(slot/链编辑不会触发注册抖动)。
167
166
  - **过期选择**:行消失(插件禁用)而会话仍选中 `FallbacksChain / Auto` 时,会话继续把它显示为当前模型,但 `routable: false`——从目录选一个真实模型即可继续(宿主原生目录语义)。
168
167
  - **能力跟随链头**:该行的模型元数据(上下文窗口、模态、推理)镜像当前生效链头;重试归属保持宽松默认——重试/失败记到被委托的真实链头,而非 `FallbacksChain` provider。完整语义 → [docs/configuration.md](docs/configuration.md)。
169
168
 
@@ -171,11 +170,11 @@ fallbacks:
171
170
 
172
171
  峰谷无忧在[首页专题](#峰谷无忧)中介绍,本节是完整参考。分时槽行按墙钟窗口轮换**生效 root 链**——适合按峰谷切换模型,且不会把墙钟轮换误认为故障降级。文案严格区分:时段轮换的日志与 UI 用**分时切换**;失败降级保持**降级切换**;会话内「模型已降级」提示只出现在失败路径。
173
172
 
174
- - **匹配顺序**:每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 `Asia/Shanghai` / UTC+8)的额外行生效——该行的模型链**取代**全时段链;无行命中则用全时段 `rootChain`。全时段行固定最后且**必选**:最后一项必须是恰好一个官方 V4 模型(Flash 或 Pro;前面的降级条目先走)。
173
+ - **匹配顺序**:每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 `Asia/Shanghai` / UTC+8)的额外行生效——该行的模型链**取代**全时段链;无行命中则用全时段 `rootChain`。全时段行固定最后且**必选**:最后一项必须是恰好一个官方模型(Flash 或 Pro;前面的降级条目先走)。
175
174
  - **预设**(冻结,不可编辑窗口):`liang-peak` = 周一至周五 09:00–12:00 **与** 14:00–18:00;`liang-valley` = 其它所有 UTC+8 时间;`glm-peak` = 周一至周五 14:00–18:00;`glm-valley` = 其余时间。一个预设 id 对应一行;设置卡的选择器不会重复提供已添加的预设。
176
175
  - **自定义行**:`start` / `end`(`HH:mm`,可跨午夜)+ 可选 `days`(0=周日…6=周六;缺省/空 = 每天)+ 模型。
177
176
  - **下一请求生效**:时段边界跨越绝不打断进行中的 step——新行在下一个 root 请求生效。轮换仅挂载生效:info 日志 + 设置卡/`/fallbacks` 状态行,无 durable 切换事件。
178
- - **设置卡**:主代理区块下分三块——**分时槽设置**(额外行:添加预设 / 添加自定义 / 删除 / 按钮或**拖拽**排序;预设行只读展示窗口摘要、仅可编辑模型链;自定义行带可编辑名称;**时区选择器**在此区块内,只要存在预设行就**锁定 Asia/Shanghai**——预设窗口是冻结的 UTC+8 常量)、**默认降级链**(all-day 链,可配置的 provider/model 选择器列表)与**默认模型**(官方 V4 Flash | Pro 二选一链头)。行可折叠为「名称 + 首个模型」。没有 `timeSlots.enabled` 总开关(添加行即开启),也没有 `rootMode` 控件。
177
+ - **设置卡**:主代理区块下分三块——**分时槽设置**(额外行:添加预设 / 添加自定义 / 删除 / 按钮或**拖拽**排序;预设行只读展示窗口摘要、仅可编辑模型链;自定义行带可编辑名称;**时区选择器**在此区块内,只要存在预设行就**锁定 Asia/Shanghai**——预设窗口是冻结的 UTC+8 常量)、**默认降级链**(all-day 链,可配置的 provider/model 选择器列表)与**默认模型**(官方 Flash | Pro 二选一链头)。行可折叠为「名称 + 首个模型」。没有 `timeSlots.enabled` 总开关(添加行即开启),也没有 `rootMode` 控件。
179
178
 
180
179
  ## 预设角色(Preset roles)
181
180
 
@@ -202,7 +201,7 @@ fallbacks:
202
201
  - CONTEXT_WINDOW_EXCEEDED # 请求装不下时降级
203
202
  rootChain:
204
203
  - anthropic/claude-3-5-sonnet # 先走
205
- - deepseek-official/deepseek-v4-flash # 兜底(Flash 或 Pro)
204
+ - deepseek-official/deepseek-flash # 兜底(Flash 或 Pro)
206
205
  ```
207
206
 
208
207
  **顺序——降级先于压缩。** 只要 `triggerCodes` 含 `CONTEXT_WINDOW_EXCEEDED`,本插件的 `agent/request-error` 监听就会**先于**宿主压缩插件处理该拒绝:首次超限即把会话切到 fallback 模型,上下文完全不会被压缩。若更希望先尝试压缩,就不要把该码列入 `triggerCodes`。
@@ -198,6 +198,12 @@ window.__ModuleLoader__.load({
198
198
  raw: trimmed
199
199
  };
200
200
  }
201
+ /** The two official all-day tail ids (XOR — exactly one legal tail). */
202
+ const OFFICIAL_ALL_DAY_IDS = ["deepseek-official/deepseek-flash", "deepseek-official/deepseek-pro"];
203
+ /** Whether `id` is one of the two official all-day tail ids. */
204
+ function isOfficialAllDayId(id) {
205
+ return id === "deepseek-official/deepseek-flash" || id === "deepseek-official/deepseek-pro";
206
+ }
201
207
  /**
202
208
  * Frozen preset windows (UTC+8). `liang-peak` and `glm-peak` are
203
209
  * Monday–Friday only (day mask `[1, 2, 3, 4, 5]`); their valleys are the
@@ -402,17 +408,18 @@ window.__ModuleLoader__.load({
402
408
  }
403
409
  /**
404
410
  * All-day conformance (P6): the all-day chain is conforming when its LAST
405
- * entry (the tail — the card's 默认模型 panel) is exactly one official V4
406
- * model — Flash XOR Pro. Leading entries (the card's 默认降级链 block) are
407
- * the ordered walk before that last-resort fallback. An empty chain or a
408
- * chain whose tail is not an official V4 model keeps slot rows inert and
409
- * refuses the virtual-row override/delegate; the v0.2.2 failure walk over
410
- * the raw chain stays verbatim.
411
+ * entry (the tail — the card's 默认模型 panel) is exactly one official
412
+ * model — Flash or Pro (XOR). Leading entries (the card's 默认降级链 block)
413
+ * are the ordered walk before that last-resort fallback. An empty chain or
414
+ * a chain whose tail is not an official model (including the retired V4
415
+ * ids) keeps slot rows inert and refuses the virtual-row
416
+ * override/delegate; the v0.2.2 failure walk over the raw chain stays
417
+ * verbatim.
411
418
  */
412
419
  function isAllDayConforming(chain) {
413
420
  if (chain.length < 1) return false;
414
421
  const tail = chain[chain.length - 1];
415
- return tail === "deepseek-official/deepseek-v4-flash" || tail === "deepseek-official/deepseek-v4-pro";
422
+ return isOfficialAllDayId(tail);
416
423
  }
417
424
  /**
418
425
  * Slot winner + display label (P5): drives 分时切换 detection (per-root-agent
@@ -1468,10 +1475,11 @@ window.__ModuleLoader__.load({
1468
1475
  "timeSlots.day.fri": "五",
1469
1476
  "timeSlots.day.sat": "六",
1470
1477
  "timeSlots.selector.add": "添加选择器",
1471
- "allDay.hint": "全天链的最后一档兜底:官方 V4 Flash 或 V4 Pro 二选一",
1472
- "allDay.flash": "官方 V4 Flash(deepseek-official/deepseek-v4-flash)",
1473
- "allDay.pro": "官方 V4 Pro(deepseek-official/deepseek-v4-pro)",
1474
- "allDay.nonconforming": "当前默认模型不合法:请选择官方 V4 Flash 或 V4 Pro 后保存",
1478
+ "allDay.hint": "全天链的最后一档兜底:官方 Flash 或 Pro 二选一",
1479
+ "allDay.flash": "官方 Flash(deepseek-official/deepseek-flash)",
1480
+ "allDay.pro": "官方 Pro(deepseek-official/deepseek-pro)",
1481
+ "allDay.proCaveat": "(暂不可用)",
1482
+ "allDay.nonconforming": "当前默认模型不合法:请选择官方 Flash 或 Pro 后保存",
1475
1483
  "roles.list.label": "角色实体",
1476
1484
  "roles.list.hint": "先声明角色,规则才能引用",
1477
1485
  "roles.list.tooltip": "角色 id 须匹配 /^[a-z0-9-]{1,32}$/ 且唯一;\"inherit\" 为保留字,不能用作角色 id。",
@@ -1514,7 +1522,7 @@ window.__ModuleLoader__.load({
1514
1522
  "validation.ruleRoleUndeclared": "规则引用了未声明的角色 \"{role}\"",
1515
1523
  "validation.ruleRoleRequired": "规则未选择角色:请选择目标角色,或删除该行",
1516
1524
  "validation.roleChainRequired": "角色 \"{id}\" 未配置模型:请至少添加一条链选择器(模型配置)",
1517
- "validation.allDayRequired": "默认模型必须二选一:官方 V4 Flash 或 V4 Pro",
1525
+ "validation.allDayRequired": "默认模型必须二选一:官方 Flash 或 Pro",
1518
1526
  "validation.slotChainRequired": "分时槽未配置模型:请至少添加一条链选择器",
1519
1527
  "validation.slotWindow": "分时槽开始/结束时间须为 HH:mm 格式",
1520
1528
  "validation.slotDays": "星期取值须为 0–6 的整数",
@@ -1663,10 +1671,11 @@ window.__ModuleLoader__.load({
1663
1671
  "timeSlots.day.fri": "Fri",
1664
1672
  "timeSlots.day.sat": "Sat",
1665
1673
  "timeSlots.selector.add": "Add selector",
1666
- "allDay.hint": "Last-resort fallback of the all-day chain: official V4 Flash or Pro (pick exactly one)",
1667
- "allDay.flash": "Official V4 Flash (deepseek-official/deepseek-v4-flash)",
1668
- "allDay.pro": "Official V4 Pro (deepseek-official/deepseek-v4-pro)",
1669
- "allDay.nonconforming": "The current default model is not valid: pick official V4 Flash or Pro before saving",
1674
+ "allDay.hint": "Last-resort fallback of the all-day chain: official Flash or Pro (pick exactly one)",
1675
+ "allDay.flash": "Official Flash (deepseek-official/deepseek-flash)",
1676
+ "allDay.pro": "Official Pro (deepseek-official/deepseek-pro)",
1677
+ "allDay.proCaveat": " (not yet available)",
1678
+ "allDay.nonconforming": "The current default model is not valid: pick official Flash or Pro before saving",
1670
1679
  "roles.list.label": "Declared roles",
1671
1680
  "roles.list.hint": "Declare roles before rules can reference them",
1672
1681
  "roles.list.tooltip": "Role ids must match /^[a-z0-9-]{1,32}$/ and be unique; \"inherit\" is reserved and cannot be used as a role id.",
@@ -1709,7 +1718,7 @@ window.__ModuleLoader__.load({
1709
1718
  "validation.ruleRoleUndeclared": "Rule references undeclared role \"{role}\"",
1710
1719
  "validation.ruleRoleRequired": "Rule has no role selected: pick a target role, or remove the row",
1711
1720
  "validation.roleChainRequired": "Role \"{id}\" has no model config: add at least one chain entry",
1712
- "validation.allDayRequired": "The default model must be exactly one official V4 model (V4 Flash or V4 Pro)",
1721
+ "validation.allDayRequired": "The default model must be exactly one official model (Flash or Pro)",
1713
1722
  "validation.slotChainRequired": "Time-slot row has no models: add at least one chain entry",
1714
1723
  "validation.slotWindow": "Time-slot start/end must use HH:mm format",
1715
1724
  "validation.slotDays": "Days must be integers 0–6",
@@ -1973,8 +1982,10 @@ window.__ModuleLoader__.load({
1973
1982
  * implemented without a store change); on a first mount the latch is false,
1974
1983
  * so the healthy card starts (and stays) collapsed through its first load.
1975
1984
  */
1976
- const ALL_DAY_FLASH = "deepseek-official/deepseek-v4-flash";
1977
- const ALL_DAY_PRO = "deepseek-official/deepseek-v4-pro";
1985
+ const ALL_DAY_LABEL_KEYS = ["allDay.flash", "allDay.pro"];
1986
+ /** Optional caveat suffix per option (parallel to OFFICIAL_ALL_DAY_IDS):
1987
+ * Pro's radio is always disabled — its model is not yet in the catalog. */
1988
+ const ALL_DAY_CAVEAT_KEYS = [void 0, "allDay.proCaveat"];
1978
1989
  const SLOT_PRESET_IDS = [
1979
1990
  "liang-peak",
1980
1991
  "liang-valley",
@@ -2027,19 +2038,19 @@ window.__ModuleLoader__.load({
2027
2038
  /** Strict 24h `HH:mm` — the resolver's HHMM_RE twin (drift-guarded by the gateway reject-on-save). */
2028
2039
  const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
2029
2040
  /**
2030
- * The 默认模型 value for a chain: the official V4 id when the chain TAIL
2031
- * is that model (Flash XOR Pro — leading 默认降级链 entries allowed);
2041
+ * The 默认模型 value for a chain: the official id when the chain TAIL
2042
+ * is that model (Flash or Pro XOR — leading 默认降级链 entries allowed);
2032
2043
  * `''` for an empty chain or a chain whose last entry is not official
2033
2044
  * (the panel reads back unselected and save validation blocks the value).
2034
2045
  */
2035
2046
  function allDayModelOf(chain) {
2036
2047
  const tail = chain.length >= 1 ? chain[chain.length - 1] : void 0;
2037
- return tail === ALL_DAY_FLASH || tail === ALL_DAY_PRO ? tail : "";
2048
+ return tail !== void 0 && isOfficialAllDayId(tail) ? tail : "";
2038
2049
  }
2039
2050
  /**
2040
- * The 默认降级链 editor row: the leading entries BEFORE the official-V4
2041
- * tail, or the whole chain while the tail is not official (the draft
2042
- * rides the accepted value until a 默认模型 pick).
2051
+ * The 默认降级链 editor row: the leading entries BEFORE the official tail
2052
+ * (Flash or Pro), or the whole chain while the tail is not official (the
2053
+ * draft rides the accepted value until a 默认模型 pick).
2043
2054
  */
2044
2055
  function allDayChainRowOf(chain, catalog) {
2045
2056
  return rootChainToRows(allDayModelOf(chain) === "" ? chain : chain.slice(0, -1), catalog)[0];
@@ -2074,8 +2085,8 @@ window.__ModuleLoader__.load({
2074
2085
  * (AC-7 re-scope, PM decision 2026-08-17 Option A).
2075
2086
  *
2076
2087
  * All-day: rootChain is composed from the 默认降级链 editor's leading
2077
- * selectors plus the 默认模型 tail (exactly one official V4 — Flash XOR
2078
- * Pro). While no tail is selected the ACCEPTED chain rides through
2088
+ * selectors plus the 默认模型 tail (exactly one official model — Flash or
2089
+ * Pro XOR). While no tail is selected the ACCEPTED chain rides through
2079
2090
  * untouched. `timeSlots` is rebuilt from the slot rows every render. `tz`
2080
2091
  * is a card scalar: preset rows lock it to Asia/Shanghai; custom rows
2081
2092
  * follow the selected timezone.
@@ -2165,7 +2176,7 @@ window.__ModuleLoader__.load({
2165
2176
  if ((role.chain ?? []).length === 0 && !seedInfo.has(role.id.trim())) errors.sub.push(t("validation.roleChainRequired", { id: role.id }));
2166
2177
  }
2167
2178
  const allDayTail = draft.rootChain.length >= 1 ? draft.rootChain[draft.rootChain.length - 1] : void 0;
2168
- if (allDayTail !== ALL_DAY_FLASH && allDayTail !== ALL_DAY_PRO) errors.main.push(t("validation.allDayRequired"));
2179
+ if (allDayTail === void 0 || !isOfficialAllDayId(allDayTail)) errors.main.push(t("validation.allDayRequired"));
2169
2180
  for (const entry of draft.rootChain) try {
2170
2181
  parseSelector(entry);
2171
2182
  } catch (error) {
@@ -3448,36 +3459,28 @@ window.__ModuleLoader__.load({
3448
3459
  className: FallbacksCard_module_css_default.list,
3449
3460
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3450
3461
  className: FallbacksCard_module_css_default.editorCard,
3451
- children: [
3452
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3453
- className: FallbacksCard_module_css_default.optionRow,
3454
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3455
- type: "radio",
3456
- name: "fallbacks-all-day",
3457
- checked: allDayModel === ALL_DAY_FLASH,
3458
- disabled: !writable,
3459
- onChange: () => {
3460
- setAllDayModel(ALL_DAY_FLASH);
3461
- }
3462
- }), t("allDay.flash")]
3463
- }),
3464
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3465
- className: FallbacksCard_module_css_default.optionRow,
3466
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3462
+ children: [OFFICIAL_ALL_DAY_IDS.map((id, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3463
+ className: FallbacksCard_module_css_default.optionRow,
3464
+ children: [
3465
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3467
3466
  type: "radio",
3468
3467
  name: "fallbacks-all-day",
3469
- checked: allDayModel === ALL_DAY_PRO,
3470
- disabled: !writable,
3468
+ checked: allDayModel === id,
3469
+ disabled: !writable || id === "deepseek-official/deepseek-pro",
3471
3470
  onChange: () => {
3472
- setAllDayModel(ALL_DAY_PRO);
3471
+ setAllDayModel(id);
3473
3472
  }
3474
- }), t("allDay.pro")]
3475
- }),
3476
- allDayModel === "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3477
- className: FallbacksCard_module_css_default.hint,
3478
- children: t("allDay.nonconforming")
3479
- })
3480
- ]
3473
+ }),
3474
+ t(ALL_DAY_LABEL_KEYS[index]),
3475
+ ALL_DAY_CAVEAT_KEYS[index] !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3476
+ className: FallbacksCard_module_css_default.hint,
3477
+ children: t(ALL_DAY_CAVEAT_KEYS[index])
3478
+ })
3479
+ ]
3480
+ }, id)), allDayModel === "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3481
+ className: FallbacksCard_module_css_default.hint,
3482
+ children: t("allDay.nonconforming")
3483
+ })]
3481
3484
  })
3482
3485
  })
3483
3486
  ]
@@ -102,6 +102,7 @@ export declare const zh: {
102
102
  'allDay.hint': string;
103
103
  'allDay.flash': string;
104
104
  'allDay.pro': string;
105
+ 'allDay.proCaveat': string;
105
106
  'allDay.nonconforming': string;
106
107
  'roles.list.label': string;
107
108
  'roles.list.hint': string;
@@ -299,6 +300,7 @@ export declare const en: {
299
300
  'allDay.hint': string;
300
301
  'allDay.flash': string;
301
302
  'allDay.pro': string;
303
+ 'allDay.proCaveat': string;
302
304
  'allDay.nonconforming': string;
303
305
  'roles.list.label': string;
304
306
  'roles.list.hint': string;
package/dist/events.d.ts CHANGED
@@ -4,9 +4,11 @@
4
4
  * Writing is stopped (issue #52): the plugin no longer appends this event —
5
5
  * the apply()-time event-type registration was proven ineffective (module
6
6
  * instance mismatch), and a session containing the event refused to load
7
- * after a dsh restart. The event now exists only in history: old logs are
8
- * repaired by `scripts/repair-fallbacks-switch-logs.ts`, which marks legacy
9
- * events ignorable so affected sessions load again.
7
+ * after a dsh restart. The event now exists only in history: old logs that
8
+ * contain it cannot be repaired by an `ignorable` flag (the released
9
+ * session-format chain refuses unknown event types even when marked
10
+ * ignorable), so `scripts/repair-fallbacks-switch-logs.ts` is a detector
11
+ * that reports such logs and never writes.
10
12
  * The module is type-only — the augmentation is erased at runtime; the
11
13
  * plugin's runtime behavior lives in `src/index.ts`.
12
14
  *
package/dist/index.js CHANGED
@@ -34,6 +34,9 @@ function resolveWildcardEntry(failingModel, provider) {
34
34
  raw: `${provider}/${failingModel}`
35
35
  };
36
36
  }
37
+ function isOfficialAllDayId(id) {
38
+ return id === "deepseek-official/deepseek-flash" || id === "deepseek-official/deepseek-pro";
39
+ }
37
40
  const PRESETS = {
38
41
  "liang-peak": {
39
42
  windows: [{
@@ -220,7 +223,7 @@ function labelOf(row) {
220
223
  function isAllDayConforming(chain) {
221
224
  if (chain.length < 1) return false;
222
225
  const tail = chain[chain.length - 1];
223
- return tail === "deepseek-official/deepseek-v4-flash" || tail === "deepseek-official/deepseek-v4-pro";
226
+ return isOfficialAllDayId(tail);
224
227
  }
225
228
  function resolveEffectiveChain(config, now, tz) {
226
229
  const state = resolveSlotState(config, now, tz);
@@ -359,7 +362,7 @@ function validateFallbacksConfig(config, logger) {
359
362
  if ((role.chain ?? []).length === 0) logger.warn(`llm-fallbacks: role "${role.id}" has no model config — declare at least one chain entry, or use the built-in "inherit" rule target instead`);
360
363
  if (role.fallback !== void 0 && role.fallback !== "inherit-root" && role.fallback !== "none") logger.warn(`llm-fallbacks: role "${role.id}" has invalid fallback "${String(role.fallback)}" — expected "inherit-root" or "none"`);
361
364
  }
362
- if (config.rootChain.length > 0 && !isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: rootChain must end with exactly one official V4 model (deepseek-official/deepseek-v4-flash or deepseek-official/deepseek-v4-pro) — time-slot rows and the virtual picker row stay inert until the all-day chain tail conforms");
365
+ if (config.rootChain.length > 0 && !isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: rootChain must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro) — time-slot rows and the virtual picker row stay inert until the all-day chain tail conforms");
363
366
  for (const entry of config.rootChain) try {
364
367
  parseSelector(entry);
365
368
  } catch (error) {
@@ -1160,7 +1163,7 @@ function validateConfigPatch(patch) {
1160
1163
  }
1161
1164
  if (key === "rootChain") {
1162
1165
  const chain = patch[key];
1163
- if (chain !== null && chain !== void 0 && (!Array.isArray(chain) || !isAllDayConforming(chain))) throw new Error("dsh-llm-fallbacks: rootChain must end with exactly one official V4 model (deepseek-official/deepseek-v4-flash or deepseek-official/deepseek-v4-pro)");
1166
+ if (chain !== null && chain !== void 0 && (!Array.isArray(chain) || !isAllDayConforming(chain))) throw new Error("dsh-llm-fallbacks: rootChain must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro)");
1164
1167
  }
1165
1168
  }
1166
1169
  Config(patch);
@@ -2058,10 +2061,10 @@ function buildFallbacksTuiSection() {
2058
2061
  zh: "根降级链(JSON)",
2059
2062
  en: "Root chain (JSON)"
2060
2063
  },
2061
- hint: "JSON array of model selectors; the last entry must be an official V4 model.",
2064
+ hint: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro). Pro is a legal tail but not yet available in the catalog — the settings card shows it disabled.",
2062
2065
  hintDescriptions: {
2063
- zh: "模型选择器 JSON 数组;末项必须是官方 V4 模型。",
2064
- en: "JSON array of model selectors; the last entry must be an official V4 model."
2066
+ zh: "模型选择器 JSON 数组;末项必须是官方模型(deepseek-official/deepseek-flash 或 deepseek-official/deepseek-pro)。Pro 是合法链尾但目录暂未提供——设置卡中显示为禁用。",
2067
+ en: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro). Pro is a legal tail but not yet available in the catalog — the settings card shows it disabled."
2065
2068
  },
2066
2069
  group: "chain",
2067
2070
  kind: "text",
@@ -2704,7 +2707,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
2704
2707
  });
2705
2708
  }
2706
2709
  if (config.enabled && seed.provider === "FallbacksChain" && seed.model === "Auto" && agent.session?.header?.origin !== "subagent") {
2707
- if (!isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: FallbacksChain/Auto selected but the all-day rootChain is not conforming (exactly one official V4 model) — no primary override");
2710
+ if (!isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: FallbacksChain/Auto selected but the all-day rootChain is not conforming (exactly one official model: deepseek-official/deepseek-flash or deepseek-official/deepseek-pro) — no primary override");
2708
2711
  else {
2709
2712
  const head = firstDispatchableExactHead(resolveEffectiveChain(config, new Date(), config.tz ?? "Asia/Shanghai"));
2710
2713
  if (head === void 0) logger.warn("llm-fallbacks: FallbacksChain/Auto selected but the effective chain has no exact head (empty, wildcard-only, or self-route) — no primary override");
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Time-slot rows for `fallbacks` (plan fallbacks-timeslots Task 1, pins
3
- * P4–P6): frozen preset windows, the official-V4 all-day conformance
3
+ * P4–P6): frozen preset windows, the official all-day conformance
4
4
  * guard, and the pure `resolveEffectiveChain` / `resolveSlotState`
5
5
  * resolver.
6
6
  *
@@ -15,9 +15,17 @@
15
15
  * @module dsh-llm-fallbacks/time-slots
16
16
  */
17
17
  import type { FallbacksConfig } from './config.ts';
18
- /** Official V4 models — the ONLY legal all-day selectors (length 1, XOR). */
19
- export declare const OFFICIAL_V4_FLASH = "deepseek-official/deepseek-v4-flash";
20
- export declare const OFFICIAL_V4_PRO = "deepseek-official/deepseek-v4-pro";
18
+ /** Official all-day selectors — the ONLY legal tails (length 1, XOR):
19
+ * Flash (the current official default, selectable) and Pro (displayed but
20
+ * disabled — its model is not yet served by the 0.1.5-rc.1 catalog). The
21
+ * retired V4 ids (`deepseek-v4-flash` / `deepseek-v4-pro`) are no longer
22
+ * legal tails. */
23
+ export declare const OFFICIAL_FLASH = "deepseek-official/deepseek-flash";
24
+ export declare const OFFICIAL_PRO = "deepseek-official/deepseek-pro";
25
+ /** The two official all-day tail ids (XOR — exactly one legal tail). */
26
+ export declare const OFFICIAL_ALL_DAY_IDS: readonly ["deepseek-official/deepseek-flash", "deepseek-official/deepseek-pro"];
27
+ /** Whether `id` is one of the two official all-day tail ids. */
28
+ export declare function isOfficialAllDayId(id: string): boolean;
21
29
  /** The four frozen preset ids (exact strings, spec lock). */
22
30
  export declare const PRESET_IDS: readonly ["liang-peak", "liang-valley", "glm-peak", "glm-valley"];
23
31
  export type PresetId = (typeof PRESET_IDS)[number];
@@ -65,12 +73,13 @@ export interface PresetDefinition {
65
73
  export declare const PRESETS: Record<PresetId, PresetDefinition>;
66
74
  /**
67
75
  * All-day conformance (P6): the all-day chain is conforming when its LAST
68
- * entry (the tail — the card's 默认模型 panel) is exactly one official V4
69
- * model — Flash XOR Pro. Leading entries (the card's 默认降级链 block) are
70
- * the ordered walk before that last-resort fallback. An empty chain or a
71
- * chain whose tail is not an official V4 model keeps slot rows inert and
72
- * refuses the virtual-row override/delegate; the v0.2.2 failure walk over
73
- * the raw chain stays verbatim.
76
+ * entry (the tail — the card's 默认模型 panel) is exactly one official
77
+ * model — Flash or Pro (XOR). Leading entries (the card's 默认降级链 block)
78
+ * are the ordered walk before that last-resort fallback. An empty chain or
79
+ * a chain whose tail is not an official model (including the retired V4
80
+ * ids) keeps slot rows inert and refuses the virtual-row
81
+ * override/delegate; the v0.2.2 failure walk over the raw chain stays
82
+ * verbatim.
74
83
  */
75
84
  export declare function isAllDayConforming(chain: readonly string[]): boolean;
76
85
  /**
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "dsh",
6
6
  "dsh-plugin"
7
7
  ],
8
- "version": "0.4.3",
8
+ "version": "0.4.4",
9
9
  "type": "module",
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",
@@ -60,45 +60,65 @@
60
60
  },
61
61
  "peerDependencies": {
62
62
  "@deepseek-ai/cordis": "^4.0.2",
63
- "@deepseek-ai/dsh-agent": "^0.1.2-rc.1",
64
- "@deepseek-ai/dsh-api-gateway": "^0.1.2-rc.1",
65
- "@deepseek-ai/dsh-api-remotes": "^0.1.2-rc.1",
66
- "@deepseek-ai/dsh-api-session-controller": "^0.1.2-rc.1",
67
- "@deepseek-ai/dsh-client-connection": "^0.1.2-rc.1",
68
- "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
69
- "@deepseek-ai/dsh-client-store": "^0.1.2-rc.1",
70
- "@deepseek-ai/dsh-client-ui-chat": "^0.1.2-rc.1",
71
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1",
72
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-rc.1",
73
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1",
74
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
75
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.2-rc.1",
76
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
77
- "@deepseek-ai/dsh-commands": "^0.1.2-rc.1",
78
- "@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
79
- "@deepseek-ai/dsh-llm-retry": "^0.1.2-rc.1",
80
- "@deepseek-ai/dsh-session": "^0.1.2-rc.1",
81
- "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
82
- "@deepseek-ai/dsh-tool-subagent": "^0.1.2-rc.1",
83
- "@deepseek-ai/dsh-typert-protocol": "^0.1.2-rc.1",
84
- "@deepseek-ai/dsh-typert-registry": "^0.1.2-rc.1",
63
+ "@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
64
+ "@deepseek-ai/dsh-api-gateway": "^0.1.5-rc.1",
65
+ "@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.1",
66
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.1",
67
+ "@deepseek-ai/dsh-client-connection": "^0.1.5-rc.1",
68
+ "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
69
+ "@deepseek-ai/dsh-client-store": "^0.1.5-rc.1",
70
+ "@deepseek-ai/dsh-client-ui-chat": "^0.1.5-rc.1",
71
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.1",
72
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.1",
73
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
74
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
75
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.1",
76
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
77
+ "@deepseek-ai/dsh-commands": "^0.1.5-rc.1",
78
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
79
+ "@deepseek-ai/dsh-llm-retry": "^0.1.5-rc.1",
80
+ "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
81
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
82
+ "@deepseek-ai/dsh-tool-subagent": "^0.1.5-rc.1",
83
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.5-rc.1",
84
+ "@deepseek-ai/dsh-typert-registry": "^0.1.5-rc.1",
85
85
  "@deepseek-ai/schemastery": "^3.18.2",
86
86
  "react": "^18.2.0"
87
87
  },
88
88
  "devDependencies": {
89
+ "@shikijs/langs": "^4.4.3",
89
90
  "@testing-library/dom": "^10.4.1",
90
91
  "@testing-library/react": "^16.3.2",
92
+ "@types/mdast": "^4.0.4",
91
93
  "@types/node": "^22.20.0",
92
94
  "@types/react": "~18.3.31",
93
95
  "@types/react-dom": "~18.3.7",
94
96
  "@types/use-sync-external-store": "^1.5.0",
97
+ "anser": "^2.3.5",
98
+ "clsx": "^2.1.1",
99
+ "immer": "^10.2.0",
95
100
  "jsdom": "29.1.1",
101
+ "katex": "^0.16.47",
102
+ "mdast-util-from-markdown": "^2.0.3",
103
+ "mdast-util-gfm": "^3.1.0",
104
+ "mdast-util-math": "^3.0.0",
105
+ "micromark-core-commonmark": "^2.0.3",
106
+ "micromark-extension-gfm": "^3.0.0",
107
+ "micromark-extension-math": "^3.1.0",
108
+ "micromark-factory-space": "^2.0.1",
109
+ "micromark-util-character": "^2.1.1",
110
+ "micromark-util-classify-character": "^2.0.1",
111
+ "micromark-util-sanitize-uri": "^2.0.1",
112
+ "micromark-util-symbol": "^2.0.1",
113
+ "micromark-util-types": "^2.0.2",
96
114
  "react": "^18.3.1",
97
115
  "react-dom": "^18.3.1",
116
+ "shiki": "^4.4.3",
98
117
  "tsdown": "^0.22.2",
99
118
  "tsx": "^4.22.4",
100
119
  "typescript": "^5.9.3",
101
- "vitest": "^4.1.8"
120
+ "vitest": "^4.1.8",
121
+ "zustand": "~4.4.7"
102
122
  },
103
123
  "repository": {
104
124
  "type": "git",
@@ -48,11 +48,15 @@ const OUT_FILE = 'index.js'
48
48
 
49
49
  /**
50
50
  * Frozen loader module table (dsh mechanism-guide): the plan-documented
51
- * composition = PLATFORM_MODULES (8 entries in 0.1.2, `web/src/platform.ts:8-15`
52
- * — the old runtime `./client` exemption became the
53
- * `@deepseek-ai/dsh-client-store` platform module, so the store VALUE import
54
- * stays external and resolves from the host loader table) + the
55
- * PRELOADED_CLIENT_EXTERNALS (empty in 0.1.2, `web/src/platform.ts:16-17`).
51
+ * composition = PLATFORM_MODULES (9 entries in 0.1.5-rc.1,
52
+ * `web/src/platform.ts:8-16` — the old runtime `./client` exemption became
53
+ * the `@deepseek-ai/dsh-client-store` platform module, so the store VALUE
54
+ * import stays external and resolves from the host loader table) + the
55
+ * host's PRELOADED_CLIENT_EXTERNALS (empty in `packages/client/web/src/platform.ts`).
56
+ * The table need not list
57
+ * every platform module — it lists what this plugin's client half resolves
58
+ * from the frozen loader table; the rc.1 addition
59
+ * `@deepseek-ai/dsh-client-ui-dockkit` is not imported by this plugin.
56
60
  * rc.8 dropped `dsh-client-web-react` (deleted
57
61
  * package — the uSES bridge is vendored in `src/client/use-snapshot.ts`),
58
62
  * `dsh-client-ui-attachment` and `dsh-client-schema-form` from the platform
@@ -1,5 +1,5 @@
1
1
  /**
2
- * repair-fallbacks-switch-logs.ts — repair session logs poisoned by the old
2
+ * repair-fallbacks-switch-logs.ts — detect session logs poisoned by the old
3
3
  * plugin's durable `fallbacks/switch` events.
4
4
  *
5
5
  * Background: until Task 1 of `fallbacks-session-event-stop-write`, the
@@ -8,60 +8,50 @@
8
8
  * is outside its baked catalog unless the event carries `ignorable: true`.
9
9
  * `Session.append` cannot write `ignorable`, and runtime registration proved
10
10
  * ineffective (module-instance mismatch). Session logs written while the old
11
- * plugin was active therefore fail to load after a dsh restart. This script
12
- * marks those events `ignorable: true` so the read path accepts them again.
11
+ * plugin was active therefore fail to load after a dsh restart.
12
+ *
13
+ * FAIL-CLOSED (measured against the published 0.1.5-rc.1 packages): the
14
+ * released session-format migration chain refuses unknown event types even
15
+ * with `ignorable: true` — the v0→v1 stage's `assertReleasedEventPayload`
16
+ * throws `format v0 contains unknown historical event type
17
+ * "fallbacks/switch" ... migration refuses unknown historical events even
18
+ * when ignorable` before any later edge runs (v1→v2 refuses unknown types
19
+ * the same way). A log whose `fallbacks/switch` events are marked
20
+ * `ignorable` therefore still fails to load, so this script NEVER writes a
21
+ * "repaired" file and never reports a repair: it detects such logs, reports
22
+ * them, and exits non-zero. The durable fix belongs upstream at the
23
+ * migration edges, not in this script.
13
24
  *
14
25
  * Session log format (`~/.dsh/sessions/<namespace>/<session-id>/session.jsonl.zstd`):
15
26
  * - concatenated-zstd-frame container: **first frame MUST decode to
16
- * exactly one header line** (rc.7 `assertZstdHeaderFrame`:
17
- * `indexOf(10) === length-1`). Subsequent frames hold events.
27
+ * exactly one header line** (0.1.5-rc.1 `assertZstdHeaderFrame`:
28
+ * `indexOf(10) === length-1` — the framing invariant is unchanged
29
+ * since rc.7). Subsequent frames hold events.
18
30
  * - `node:zlib.zstdDecompress` only decodes the FIRST frame, so this
19
31
  * script shells out to the `zstd` CLI (`zstd -d -c` decodes every
20
- * concatenated frame). Re-encoding MUST emit frame-1 = header only
21
- * + a following frame for the rest — a single-frame rewrite of the
22
- * whole log fails host boot (`first frame is not exactly one header line`).
23
- *
24
- * IMPORTANT: stop dsh before running with `--apply`. The script replaces a
25
- * session log via read → transform → atomic rename; a live dsh that appends
26
- * a new zstd frame between the read and the rename would have that frame
27
- * lost (read-modify-write race). Report / `--dry-run` runs are safe any
28
- * time — they never write files.
32
+ * concatenated frame).
29
33
  *
30
34
  * Usage (from the repo root, via tsx — the package.json
31
35
  * `repair:fallbacks-switch-logs` script):
32
36
  * pnpm repair:fallbacks-switch-logs -- --dry-run
33
- * pnpm repair:fallbacks-switch-logs -- --apply --backup
34
- * pnpm repair:fallbacks-switch-logs -- --root /tmp/repair-fixture --dry-run
37
+ * pnpm repair:fallbacks-switch-logs -- --root /tmp/repair-fixture
35
38
  *
36
39
  * Flags:
37
40
  * --root <dir> session root to walk (default: ~/.dsh/sessions)
38
- * --dry-run report only — never write files
39
- * --backup required with --apply: copy the original to <file>.bak
40
- * before replacing (apply is refused without it)
41
- * --apply actually replace repaired files; requires --backup and a
42
- * stopped dsh; without it (or with --dry-run) the run only
43
- * reports would-change
41
+ * --dry-run report only (the only mode — no write is ever performed)
42
+ * --backup accepted for backward compatibility (no write is ever
43
+ * performed)
44
+ * --apply accepted for backward compatibility (no write is ever
45
+ * performed)
44
46
  *
45
- * Safe by construction: a file is only replaced after its re-encoded form
46
- * passes `zstd -t`, has a one-line first frame, and carries the original
47
- * file's permission bits; a file that fails to decompress or re-encode is
48
- * reported and skipped (never corrupted); the replace is an atomic rename
49
- * of a temp file in the same directory. Report/dry-run mode performs no
50
- * filesystem writes at all.
47
+ * Safe by construction: the script never writes files. A log that fails to
48
+ * decompress is reported and skipped; a log containing `fallbacks/switch`
49
+ * events is reported as unrepairable and counts as an error (exit non-zero).
51
50
  */
52
51
  import { execFileSync } from 'node:child_process'
53
- import {
54
- chmodSync,
55
- copyFileSync,
56
- existsSync,
57
- readdirSync,
58
- renameSync,
59
- rmSync,
60
- statSync,
61
- writeFileSync,
62
- } from 'node:fs'
52
+ import { existsSync, readdirSync } from 'node:fs'
63
53
  import { homedir } from 'node:os'
64
- import { basename, dirname, join } from 'node:path'
54
+ import { join } from 'node:path'
65
55
  import { pathToFileURL } from 'node:url'
66
56
 
67
57
  /** Decoded plaintext cap for one session log (512 MB; logs can be large). */
@@ -83,13 +73,17 @@ export interface MarkFallbacksSwitchIgnorableResult {
83
73
  * - every other line (non-switch events, malformed JSON, empty lines, switch
84
74
  * events that already carry `ignorable`) passes through byte-identical;
85
75
  * - switch events are re-serialized with `ignorable` appended (insertion
86
- * order — the read path only reads `event.ignorable`, so field position is
87
- * irrelevant); `seq`/`time`/`data` are preserved verbatim;
76
+ * order — the released read path only reads `event.ignorable`, so field
77
+ * position is irrelevant); `seq`/`time`/`data` are preserved verbatim;
88
78
  * - `changed` counts only lines that were modified.
89
79
  *
90
80
  * Matching is on the parsed `type` field, never on the raw string — the
91
81
  * substring `fallbacks/switch` legitimately appears inside user/message data
92
82
  * and must not be treated as an event.
83
+ *
84
+ * NOTE: this transform is retained as the pure boundary the regression tests
85
+ * pin, but its output is REFUSED by the released session-format chain (see
86
+ * the module docblock) — the CLI never writes it.
93
87
  */
94
88
  export function markFallbacksSwitchIgnorable(lines: string[]): MarkFallbacksSwitchIgnorableResult {
95
89
  const out = new Array<string>(lines.length)
@@ -130,26 +124,54 @@ export function markFallbacksSwitchIgnorable(lines: string[]): MarkFallbacksSwit
130
124
  return { lines: out, changed }
131
125
  }
132
126
 
127
+ /**
128
+ * Count every parsed `fallbacks/switch` row in a log, regardless of whether
129
+ * it already carries an `ignorable` field or what its value is. The released
130
+ * session-format chain refuses the unknown event type even when `ignorable`
131
+ * is present, so ANY such row makes the log unrepairable — this is the
132
+ * fail-closed detection, separate from the pure transform's `changed` count
133
+ * (which only counts rows the transform would modify).
134
+ *
135
+ * Detection is valid-JSON-only: an unparseable line (e.g. a truncated row)
136
+ * is skipped, so a log whose switch row is itself malformed classifies as
137
+ * `unchanged` — the raw-substring alternative is deliberately rejected
138
+ * because `fallbacks/switch` legitimately appears inside user data.
139
+ */
140
+ export function countFallbacksSwitchRows(lines: string[]): number {
141
+ let count = 0
142
+ for (const line of lines) {
143
+ if (line === '') continue
144
+ let parsed: unknown
145
+ try {
146
+ parsed = JSON.parse(line)
147
+ } catch {
148
+ continue
149
+ }
150
+ if (parsed === null || typeof parsed !== 'object') continue
151
+ if ((parsed as Record<string, unknown>).type === SWITCH_TYPE) count++
152
+ }
153
+ return count
154
+ }
155
+
133
156
  /* ------------------------------------------------------------------ */
134
157
  /* CLI */
135
158
  /* ------------------------------------------------------------------ */
136
159
 
137
160
  interface CliOptions {
138
161
  root: string
139
- dryRun: boolean
140
- backup: boolean
141
- apply: boolean
142
162
  }
143
163
 
144
164
  function usage(): string {
145
165
  return `usage: tsx scripts/repair-fallbacks-switch-logs.ts [--root DIR] [--dry-run] [--backup] [--apply]
146
166
 
147
167
  --root DIR session root to walk (default: ~/.dsh/sessions)
148
- --dry-run report only — never write files
149
- --backup required with --apply: copy the original to <file>.bak before
150
- replacing
151
- --apply actually replace repaired files (requires --backup; stop dsh
152
- before applying)`
168
+ --dry-run report only (the only mode — no write is ever performed)
169
+ --backup accepted for backward compatibility (no write is ever performed)
170
+ --apply accepted for backward compatibility (no write is ever performed)
171
+
172
+ The released session-format chain (v0→v1) refuses unknown event types even
173
+ when marked ignorable, so this script fails closed: it only reports logs
174
+ that contain fallbacks/switch events and never writes a "repaired" file.`
153
175
  }
154
176
 
155
177
  function expandHome(p: string): string {
@@ -159,9 +181,6 @@ function expandHome(p: string): string {
159
181
 
160
182
  export function parseArgs(argv: string[]): CliOptions {
161
183
  let root = join(homedir(), '.dsh', 'sessions')
162
- let dryRun = false
163
- let backup = false
164
- let apply = false
165
184
  for (let i = 0; i < argv.length; i++) {
166
185
  const arg = argv[i]
167
186
  // pnpm run <script> -- <args> forwards the separator `--`; skip it.
@@ -173,24 +192,16 @@ export function parseArgs(argv: string[]): CliOptions {
173
192
  root = expandHome(argv[i])
174
193
  break
175
194
  case '--dry-run':
176
- dryRun = true
177
- break
178
195
  case '--backup':
179
- backup = true
180
- break
181
196
  case '--apply':
182
- apply = true
197
+ // Legacy mutation-era flags: accepted as no-ops — the tool never
198
+ // writes, so no flag controls a write or a precondition.
183
199
  break
184
200
  default:
185
201
  throw new Error(`${usage()}\n\nunknown argument: ${arg}`)
186
202
  }
187
203
  }
188
- if (apply && !backup) {
189
- throw new Error(
190
- `${usage()}\n\n--apply requires --backup: real modification must keep a .bak copy of every replaced session log (run with --dry-run first to review)`,
191
- )
192
- }
193
- return { root, dryRun, backup, apply }
204
+ return { root }
194
205
  }
195
206
 
196
207
  /** Resolve the zstd CLI binary; throw a clear error when it is missing. */
@@ -231,37 +242,16 @@ function findSessionLogs(root: string): string[] {
231
242
 
232
243
  type FileOutcome =
233
244
  | { action: 'unchanged'; changed: 0 }
234
- | { action: 'would-change'; changed: number }
235
- | { action: 'changed'; changed: number }
245
+ | { action: 'refused'; changed: number; error: string }
236
246
  | { action: 'error'; changed: number; error: string }
237
247
 
238
248
  /**
239
- * Encode repaired plaintext as concatenated zstd frames that rc.7 will
240
- * accept: frame 1 = header line + `\n` only; frame 2 = remaining lines.
249
+ * Decompress one log and classify it: `unchanged` when it carries no
250
+ * `fallbacks/switch` rows, `refused` when it does (the released chain
251
+ * rejects the unknown event type even when `ignorable` is present — see the
252
+ * module docblock), or `error` when it cannot be decompressed. Never writes.
241
253
  */
242
- export function encodeRepairedSessionLog(zstd: string, lines: string[]): Buffer {
243
- const header = lines[0] ?? ''
244
- const headerPlain = header.endsWith('\n') ? header : `${header}\n`
245
- const zstdOut = { input: headerPlain, maxBuffer: MAX_BUFFER }
246
- const frame1 = execFileSync(zstd, ['-c'], zstdOut)
247
- const rest = lines.slice(1)
248
- if (rest.length === 0 || (rest.length === 1 && rest[0] === '')) return frame1
249
- const restPlain = rest.join('\n')
250
- const frame2 = execFileSync(zstd, ['-c'], {
251
- input: restPlain.endsWith('\n') ? restPlain : `${restPlain}\n`,
252
- maxBuffer: MAX_BUFFER,
253
- })
254
- return Buffer.concat([frame1, frame2])
255
- }
256
-
257
- /**
258
- * Decompress all frames of one log, transform it, and (only when actually
259
- * applying) atomically replace it with a two-frame re-encode (header frame
260
- * + events frame) that passed `zstd -t` and carries the original file's
261
- * permission bits. Report/dry-run returns would-change WITHOUT touching the
262
- * filesystem.
263
- */
264
- export function processFile(zstd: string, file: string, opts: CliOptions): FileOutcome {
254
+ export function processFile(zstd: string, file: string, _opts: CliOptions): FileOutcome {
265
255
  let plain: string
266
256
  try {
267
257
  plain = execFileSync(zstd, ['-d', '-c', file], { encoding: 'utf8', maxBuffer: MAX_BUFFER })
@@ -273,36 +263,20 @@ export function processFile(zstd: string, file: string, opts: CliOptions): FileO
273
263
  }
274
264
  }
275
265
 
276
- const { lines, changed } = markFallbacksSwitchIgnorable(plain.split('\n'))
277
- if (changed === 0) return { action: 'unchanged', changed: 0 }
266
+ const switchRows = countFallbacksSwitchRows(plain.split('\n'))
267
+ if (switchRows === 0) return { action: 'unchanged', changed: 0 }
278
268
 
279
- // Report / dry-run: nothing to write — return would-change right after the
280
- // transform (parseArgs refuses `--apply` without `--backup`, so any run
281
- // reaching here without `write` is a report-only invocation).
282
- const write = opts.apply && !opts.dryRun
283
- if (!write) return { action: 'would-change', changed }
284
-
285
- // Apply: temp files live next to the target so the final rename is atomic
286
- // (same filesystem) and the walker never mistakes them for session logs.
287
- const dir = dirname(file)
288
- const tmpZstd = join(dir, `.${basename(file)}.${process.pid}.zstd.tmp`)
289
- const mode = statSync(file).mode & 0o7777
290
- try {
291
- writeFileSync(tmpZstd, encodeRepairedSessionLog(zstd, lines))
292
- execFileSync(zstd, ['-t', tmpZstd], { stdio: 'ignore' })
293
- copyFileSync(file, `${file}.bak`)
294
- chmodSync(tmpZstd, mode)
295
- renameSync(tmpZstd, file)
296
- } catch (err) {
297
- return {
298
- action: 'error',
299
- changed,
300
- error: `re-encode/validate failed: ${err instanceof Error ? err.message : String(err)}`,
301
- }
302
- } finally {
303
- rmSync(tmpZstd, { force: true })
269
+ // Fail closed (session format v3 — measured against the published
270
+ // 0.1.5-rc.1 packages): the released migration chain refuses unknown
271
+ // event types even with `ignorable: true` (the v0→v1 stage throws before
272
+ // any later edge runs), so a "repaired" log would still be rejected on
273
+ // load. Never report it as a repair and never write it.
274
+ return {
275
+ action: 'refused',
276
+ changed: switchRows,
277
+ error:
278
+ 'fallbacks/switch events cannot be repaired by an ignorable flag: the released session-format v0→v1 migration refuses unknown event types even when ignorable (session format v3)',
304
279
  }
305
- return { action: 'changed', changed }
306
280
  }
307
281
 
308
282
  export function main(): void {
@@ -319,11 +293,10 @@ export function main(): void {
319
293
  return
320
294
  }
321
295
 
322
- const mode = opts.dryRun ? 'dry-run' : opts.apply ? 'apply' : 'report'
323
296
  console.log(`root: ${opts.root}`)
324
- console.log(`mode: ${mode}${opts.backup ? ' (backup .bak before replace)' : ''}`)
325
297
  console.log(`zstd: ${zstd}`)
326
298
  console.log(`files: ${files.length}`)
299
+ console.log('mode: report only — no write is ever performed (the released session-format chain refuses ignorable-marked unknown events)')
327
300
 
328
301
  let totalFiles = 0
329
302
  let totalEvents = 0
@@ -336,15 +309,11 @@ export function main(): void {
336
309
  skipped++
337
310
  console.log(` unchanged ${file}`)
338
311
  break
339
- case 'would-change':
312
+ case 'refused':
340
313
  totalFiles++
341
314
  totalEvents += outcome.changed
342
- console.log(` would-change ${outcome.changed} ${file}`)
343
- break
344
- case 'changed':
345
- totalFiles++
346
- totalEvents += outcome.changed
347
- console.log(` changed ${outcome.changed} ${file}`)
315
+ errors++
316
+ console.error(` refused ${file}: ${outcome.error}`)
348
317
  break
349
318
  case 'error':
350
319
  errors++
@@ -354,8 +323,7 @@ export function main(): void {
354
323
  }
355
324
 
356
325
  console.log(
357
- `\nsummary: ${totalFiles} file(s), ${totalEvents} fallbacks/switch event(s) ` +
358
- `${mode === 'dry-run' || mode === 'report' ? 'to be marked' : 'marked'}, ` +
326
+ `\nsummary: ${totalFiles} file(s) refused (${totalEvents} fallbacks/switch event(s) cannot be repaired by an ignorable flag), ` +
359
327
  `${skipped} unchanged skipped, ${errors} error(s)`,
360
328
  )
361
329
  if (errors) process.exitCode = 1