dsh-session-guard 4.1.5 → 4.1.15

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.
@@ -14,10 +14,12 @@
14
14
  *
15
15
  * 构建:tsdown → lib/client.js(__ModuleLoader__.load 注册,与 input-traffic 同构)。
16
16
  */
17
+ import { createElement } from 'react'
18
+ import type { JSX } from 'react'
17
19
  import { StatusBadge } from './status-badge'
18
20
  import { PauseButton } from './pause-button'
19
21
  import { SessionGuardCard } from './settings-card'
20
- import { ShellSettingsEntry } from './shell-overlay'
22
+ import { BridgeDocHandle } from './settings-bridge.ts'
21
23
  import { GuardFamilySection, type FamilyTabEntry } from './family-section'
22
24
  import type { SessionGuardConfig } from './settings-card'
23
25
  import type { SettingsScope } from './scope-face'
@@ -39,8 +41,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
39
41
  * 本卡片改经 inject 工厂拿自己的 scope(与 dsh-family.tab 注册同源)。
40
42
  */
41
43
  'plugins.bundle.config': { kind: 'keyed'; scope: 'root'; owner: Record<string, unknown> }
42
- /** 无设置页宿主(0.1.0/0.1.1)的兜底配置入口(壳级 overlay)。 */
43
- 'shell.overlay': { kind: 'list'; scope: 'root' }
44
44
  }
45
45
  }
46
46
 
@@ -48,12 +48,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
48
48
  const FAMILY_SECTION_ID = 'dsh-family'
49
49
  /** 家族子席位 key(与 thinking-levels 的声明严格一致)。 */
50
50
  const FAMILY_CHILD_KEY = 'dsh-family.tab'
51
- /**
52
- * 接管宽限期:等宿主(以及排名更前的贡献方)先落座。下载量排名 guard(1522)
53
- * 高于 session-steward(1090) → guard 宽限期更短、先尝试接管;并发冲突由
54
- * ui-slots 的「同 id 同 priority 重复注册抛错」仲裁,后到者放弃即可。
55
- */
56
- const FAMILY_HOST_GRACE_MS = 2000
51
+ /** 让位宽限:声明批内 TL 的注册微秒级落账,此延迟后查账本足以分辨「TL 在场」。 */
52
+ const FAMILY_YIELD_MS = 500
57
53
 
58
54
  /**
59
55
  * 客户端所需服务:slots(状态徽标)+ locale。持久设置走双代腰
@@ -66,7 +62,11 @@ export const inject = ['slots', 'locale']
66
62
  interface SlotsFace {
67
63
  inject: (_name: string, _fn: () => unknown) => () => void
68
64
  register: (_options: Record<string, unknown>, _component: unknown) => () => void
69
- entries: (_name: string) => Array<{ options: { id?: string; order?: number; label?: unknown } }>
65
+ entries: (_name: string) => Array<{
66
+ options: { id?: string; order?: number; label?: unknown }
67
+ component?: unknown
68
+ inject?: () => unknown
69
+ }>
70
70
  getVersion: (_name: string) => number
71
71
  subscribe: (_name: string, _listener: () => void) => () => void
72
72
  }
@@ -95,8 +95,9 @@ function safe(tag: string, fn: () => void): void {
95
95
 
96
96
  /**
97
97
  * 双代持久设置腰(移植 input-traffic registerSettingsFaces):两个 scoped
98
- * 子注入各自产出同形 scope 后回调;0.2.0 两代并存时以 configForms 先到为准
99
- * (mounted 位防双挂载,ui-slots 同 id 仲裁兜底)。
98
+ * 子注入各自产出同形 scope 后回调。M4 P0 重构后本函数**只承担数据臂**——
99
+ * 回调仅写 scopeRef 活引用(0.1.7+ 原生优先,mounted 位防双挂),卡面注册
100
+ * 已全部提到 apply 顶层(≤0.1.5 回调永不触发,注册不能关在这里)。
100
101
  */
101
102
  function registerSettingsFaces(
102
103
  ctx: ClientCtx,
@@ -115,8 +116,23 @@ function registerSettingsFaces(
115
116
  })
116
117
  }
117
118
 
118
- /** 两张配置卡(family tab + bundle 详情页)挂载;scope 由双代腰产出。 */
119
- function mountConfigCards(ctx: ClientCtx, scope: SettingsScope<SessionGuardConfig>): void {
119
+ /** scope 活引用(perm-gate client/index.ts L166-257 形态):回调填、卡面现读。 */
120
+ const scopeRef: { scope: SettingsScope<SessionGuardConfig> | undefined } = { scope: undefined }
121
+
122
+ /** 桥轨惰性单例:≤0.1.5(settingsScope 死路 / 0.1.0-0.1.1 无服务)的数据源。 */
123
+ let bridgeScopeSingleton: BridgeDocHandle<SessionGuardConfig> | undefined
124
+ function bridgeScope(): BridgeDocHandle<SessionGuardConfig> {
125
+ if (bridgeScopeSingleton === undefined) bridgeScopeSingleton = new BridgeDocHandle<SessionGuardConfig>('session-guard')
126
+ return bridgeScopeSingleton
127
+ }
128
+
129
+ /** 卡面现取数据源:原生句柄解到即原生,否则桥兜底(perm-gate 同式)。 */
130
+ function currentScope(): SettingsScope<SessionGuardConfig> {
131
+ return scopeRef.scope ?? bridgeScope()
132
+ }
133
+
134
+ /** 两张配置卡(family tab + bundle 详情页)挂载;数据经 currentScope() 现取。 */
135
+ function mountConfigCards(ctx: ClientCtx): void {
120
136
  // 插件族共用 tab 贡献卡:读写本插件 entry(`session-guard`)的持久配置。
121
137
  ctx.slots.inject('dsh-family.tab', () => ctx.slots.register({
122
138
  name: 'dsh-family.tab',
@@ -124,7 +140,7 @@ function mountConfigCards(ctx: ClientCtx, scope: SettingsScope<SessionGuardConfi
124
140
  order: 20,
125
141
  label: '会话守护门禁',
126
142
  locale: 'session-guard',
127
- inject: () => ({ scope }),
143
+ inject: () => ({ scope: currentScope() }),
128
144
  }, SessionGuardCard))
129
145
 
130
146
  // 插件页配置卡(DSH 0.2.0):Plugins 页不自动渲染 volatile 配置表单,须注册
@@ -133,7 +149,7 @@ function mountConfigCards(ctx: ClientCtx, scope: SettingsScope<SessionGuardConfi
133
149
  ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
134
150
  name: 'plugins.bundle.config',
135
151
  key: 'dsh-session-guard',
136
- inject: () => ({ scope }),
152
+ inject: () => ({ scope: currentScope() }),
137
153
  }, SessionGuardCard))
138
154
  }
139
155
 
@@ -155,56 +171,97 @@ export function apply(ctx: ClientCtx) {
155
171
  locale: 'session-guard',
156
172
  }, StatusBadge)))
157
173
 
158
- // 双代腰:scope 就绪后按代挂载(每条宿主线恰好一代服务)。
174
+ // ── 配置卡三面(M4 P0 重构:注册提到 apply 顶层)────────────────────
175
+ // 旧形态把注册关在 onScope 回调里——≤0.1.5 settingsScope 死路/0.1.0-0.1.1
176
+ // 无服务 → 回调永不触发 → 三面永不注册、桥无消费者(perm-gate client/
177
+ // index.ts L160-165 注释点名的反例)。现注册无条件执行,数据经 currentScope()
178
+ // 现取:原生句柄解到即原生,否则桥兜底。
179
+ safe('config-cards/family-tab', () => mountConfigCards(ctx))
180
+ // 老设置页(≤0.1.5)插件列表配置卡(settings.plugin.item)已于 2026-10-08
181
+ // 撤除(family-insection 第二轮):与会话守护门禁家族 tab 重复——家族 tab
182
+ // 是唯一设置面(TL/free-search/browser-cdp/preset-manager 同批撤各自 item 卡)。
183
+
184
+ // 双代腰 = 纯数据臂,**按代分流**(2026-10-08 深夜五格实测裁决,findings「实测裁决」节):
185
+ // - configForms(0.1.7+):句柄写读皆活 → 入 scopeRef(原生优先,桥自动让位)。
186
+ // - settingsScope(0.1.2/0.1.5):句柄**写活读死**——set() 落盘持久化,但
187
+ // getSnapshot().value 恒 undefined(卡渲染默认值,磁盘是真值)。留空
188
+ // scopeRef → currentScope() 落桥:桥 mutate 同样落宿主服务(写行为等价),
189
+ // describe 读到持久化真值(读路径痊愈)。0.1.0/0.1.1 无 client 服务,
190
+ // 同样落桥。
159
191
  registerSettingsFaces(ctx, (scope, generation) => {
160
- safe(`config-cards/${generation}`, () => {
161
- // 两代共有:family tab(0.1.7+ 由 thinking-levels 容器渲染)与
162
- // bundle 详情页平铺卡(0.1.7+ 的 plugin-manager 槽)。
163
- mountConfigCards(ctx, scope)
164
- // 老设置页(≤0.1.5)插件列表配置卡;0.1.7+ 无此槽,注册静默闲置。
165
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
166
- name: 'settings.plugin.item',
167
- id: 'session-guard',
168
- key: 'session-guard',
169
- order: 60,
170
- locale: 'session-guard',
171
- inject: () => ({ scope }),
172
- }, SessionGuardCard))
173
- })
174
- // 无设置页宿主(0.1.0/0.1.1)的兜底配置入口:壳级 overlay 浮窗齿轮。
175
- safe('shell.overlay', () => ctx.slots.inject('shell.overlay', () => ctx.slots.register(
176
- { name: 'shell.overlay', id: 'session-guard-settings' },
177
- () => ShellSettingsEntry({ scope }),
178
- )))
192
+ if (generation === 'configForms') scopeRef.scope = scope
179
193
  })
180
194
 
181
- // 家族节宿主选举:仅 configForms 代(0.1.7+;族面容器 thinking-levels 也只在
182
- // 该代)。≤0.1.5 的设置壳不渲染 children 子槽,自起只会得到空节(实测),
183
- // 故经 configForms 服务探测把选举门在该代之外。宽限期后若 settings.section
184
- // 里没有 id `dsh-family` 的条目,则按同一 id 接管;与 session-steward 的
185
- // 并发竞争由 ui-slots 同 id 重复注册抛错仲裁,先到者胜。
195
+ // 家族节接管(2026-10 对齐 TL route A + bindInjectHooks 实证):让位选举。
196
+ // 2026-10-08 T20-a 实证修正:≤0.1.7 壳的设置导航对同 id 异 priority 的遮蔽位
197
+ // 也各列一行——「TL(p0) 恒头位、本插件 p10 恒阴影不占导航」假设不成立,双注册
198
+ // = 双「起子插件设置」导航行(0.1.0~0.1.7 五格全现;0.1.0 摘除 guard 实验:
199
+ // 单行归位、TL 节独座满血)。改为延迟让位:声明批内 TL 同批落账(工厂 fire
200
+ // 顺序=inject 注册顺序,本插件的 inject 先注册先 fire,TL 同批紧随),延迟
201
+ // FAMILY_YIELD_MS 再查账本——已有 dsh-family(TL 头位落座)→ 让位退场;仍空
202
+ // (TL 缺席/落账失败)→ 接管。inject 仍必须 apply 期注册——slots 核只在「声明
203
+ // 事件」触发工厂,宽限期后注册的 inject 等不到下一次声明(0.1.0 实机日志定案:
204
+ // 4.1.7/4.1.8 的 grace 后注册工厂零执行);register 本就是指令式调用,移入
205
+ // 定时器不改变机制。子席位声明带冲突兜底:TL 先声明 → 去 children 重试(接管
206
+ // 者不重复声明);同 id 同 priority 竞争 → 双抛,放弃。register 走本工厂触发
207
+ // 管道(裸注册的 entry.inject 在 ≤0.1.5 壳上不解,4.1.5「半补位」真相)。
186
208
  safe('family-section-election', () => {
187
- setTimeout(() => {
188
- if (ctx.slots.entries('settings.section').some(e => e.options.id === FAMILY_SECTION_ID)) return
189
- ctx.inject(['configForms'], () => {
190
- if (ctx.slots.entries('settings.section').some(e => e.options.id === FAMILY_SECTION_ID)) return
209
+ console.log('[dsh-session-guard] election: registering settings.section inject (apply-time)')
210
+ ctx.slots.inject('settings.section', () => {
211
+ console.log('[dsh-session-guard] election: factory fired (settings.section declared)')
212
+ setTimeout(() => {
213
+ if (ctx.slots.entries('settings.section').some(e => e.options.id === FAMILY_SECTION_ID)) {
214
+ console.log('[dsh-session-guard] election: dsh-family already seated — stand down')
215
+ return
216
+ }
217
+ const base = {
218
+ name: 'settings.section',
219
+ id: FAMILY_SECTION_ID,
220
+ order: 40,
221
+ priority: 10,
222
+ label: () => '起子插件设置',
223
+ inject: () => ({
224
+ hooks: makeGuardFamilyTabsHooks(ctx.slots),
225
+ renderContributor,
226
+ }),
227
+ }
191
228
  try {
192
229
  ctx.slots.register({
193
- name: 'settings.section',
194
- id: FAMILY_SECTION_ID,
195
- order: 40,
196
- label: () => '起子插件设置',
197
- inject: () => makeGuardFamilyTabsHooks(ctx.slots),
230
+ ...base,
198
231
  children: { [FAMILY_CHILD_KEY]: { kind: 'list', scope: 'root' } },
199
232
  }, GuardFamilySection)
200
- } catch {
201
- // 并发接管竞争落败(或设置壳未声明席位):胜出方的节服务整个家族。
233
+ console.log('[dsh-session-guard] family takeover registered (children declared)')
234
+ } catch (error) {
235
+ console.log(`[dsh-session-guard] children decl conflict (${error instanceof Error ? error.message : error}); registering without children`)
236
+ try {
237
+ ctx.slots.register(base, GuardFamilySection)
238
+ console.log('[dsh-session-guard] family takeover registered (no children)')
239
+ } catch (secondary) {
240
+ console.log(`[dsh-session-guard] family takeover abandoned: ${secondary instanceof Error ? secondary.message : secondary}`)
241
+ }
202
242
  }
203
- })
204
- }, FAMILY_HOST_GRACE_MS)
243
+ }, FAMILY_YIELD_MS)
244
+ })
205
245
  })
206
246
  }
207
247
 
248
+ /** 直接挂载面(≤0.1.5 壳无 renderSlot 的降级):从账本原始条目现挂激活卡。
249
+ * entries 每代都带 component + inject(ui-slots SlotCore.entries 原始记录);
250
+ * 任何失败(条目消失/抛错 inject)降级为 null,不炸接管节。 */
251
+ function renderContributor(id: string): JSX.Element | null {
252
+ try {
253
+ const entry = ctx.slots.entries(FAMILY_CHILD_KEY).find(e => e.options.id === id)
254
+ if (entry === undefined || typeof entry.component !== 'function') return null
255
+ const injected = typeof entry.inject === 'function' ? entry.inject() : undefined
256
+ return createElement(
257
+ entry.component as (props: Record<string, unknown>) => JSX.Element | null,
258
+ (injected ?? {}) as Record<string, unknown>,
259
+ )
260
+ } catch {
261
+ return null
262
+ }
263
+ }
264
+
208
265
  /** 接管节的账本投影 hooks(与 thinking-levels 的 FamilySectionInjected 同形)。 */
209
266
  function makeGuardFamilyTabsHooks(slots: SlotsFace): {
210
267
  getSnapshot: () => readonly FamilyTabEntry[]
@@ -0,0 +1,202 @@
1
+ /**
2
+ * BridgeDocHandle — 设置卡双轨数据源的桥轨(M4/T20-b)。
3
+ *
4
+ * ≤0.1.5(settingsScope fiber 永不解 / 0.1.0-0.1.1 无 client 服务)时,
5
+ * 配置卡经本句柄走自家 webServer 桥(describe/mutate)读写宿主 settings 服务。
6
+ * 与原生 SettingsScope(scope-face.ts)同构:getSnapshot/subscribe/set/unset。
7
+ * 读 = POST /api/<ns>/settings/describe;写 = POST …/mutate(ops 转发宿主服务),
8
+ * 成功即用回填描述符刷新快照并通知订阅者(无本地第二份状态——权威值始终在宿主)。
9
+ *
10
+ * 与 perm-gate src/client/bridge-scope.ts(T13b/T20-b 泛化模板)的差异:
11
+ * ① 快照形状对齐本仓 SettingsScope(status: loading/ready/unavailable + mode);
12
+ * ② describe 未就绪(settings-unavailable/ns-not-registered)时有限重试
13
+ * (宿主 settings 服务解析晚于卡挂载的窗口),重试耗尽才落 'unavailable'。
14
+ */
15
+
16
+ /** 与 server 侧 settingsBridgeRoutesFor(src/settings-bridge.js)必须逐字符一致。 */
17
+ function settingsBridgeRoutesFor(ns: string): { describe: string; mutate: string } {
18
+ return { describe: `/api/${ns}/settings/describe`, mutate: `/api/${ns}/settings/mutate` }
19
+ }
20
+
21
+ interface Snapshot<T> {
22
+ status: 'loading' | 'ready' | 'unavailable'
23
+ value: T | undefined
24
+ revision: number | undefined
25
+ writable: boolean
26
+ base: unknown
27
+ user: unknown
28
+ mode: 'bridge'
29
+ }
30
+
31
+ interface Descriptor {
32
+ value?: unknown
33
+ revision?: number
34
+ [k: string]: unknown
35
+ }
36
+
37
+ interface BridgeBody {
38
+ ok?: boolean
39
+ code?: string
40
+ message?: string
41
+ value?: { descriptor?: Descriptor; writable?: boolean }
42
+ }
43
+
44
+ interface MutateOp {
45
+ op: 'set' | 'unset'
46
+ path: string[]
47
+ value?: unknown
48
+ }
49
+
50
+ /**
51
+ * ≤0.1.5 宿主 describe 的 descriptor.value 是「每字段 → {}」骨架(0.1.0/0.1.5
52
+ * 实测:value={enabled:{},…,officialProviders:{}},真值拆在 base(合并默认层)
53
+ * 与 user(用户层))——直接吞 value 会让卡拿到 officialProviders={},
54
+ * `?? []` 不兜底({} 非 nullish)→ ListRow values.join 崩,整族 tab 槽殉葬。
55
+ * 判特征:对象且每个自有值都是空 plain 对象(guard 字段是标量/数组,不会全空)。
56
+ * 命中即以 {...base, ...user} 重组有效值(guard 配置全是顶层标量/数组,浅合并即语义)。
57
+ */
58
+ function isFieldSkeleton(v: unknown): v is Record<string, Record<string, never>> {
59
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
60
+ const values = Object.values(v as Record<string, unknown>)
61
+ return values.length > 0
62
+ && values.every((x) => x !== null && typeof x === 'object' && !Array.isArray(x) && Object.keys(x as object).length === 0)
63
+ }
64
+
65
+ function plainObject(v: unknown): Record<string, unknown> {
66
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? { ...(v as Record<string, unknown>) } : {}
67
+ }
68
+
69
+ /** descriptor → 卡面可用 plain 值:正常代际用 value;骨架代际用 base+user 浅合并。 */
70
+ function descriptorPlainValue(d: Descriptor): unknown {
71
+ if (!isFieldSkeleton(d.value)) return d.value
72
+ return { ...plainObject(d.base), ...plainObject(d.user) }
73
+ }
74
+
75
+ /** describe 不可就绪码:宿主 settings 未解析/未注册——值得重试的瞬态。 */
76
+ const RETRYABLE_CODES = new Set(['settings-unavailable', 'ns-not-registered'])
77
+ const RETRY_DELAY_MS = 1500
78
+ const RETRY_MAX = 4
79
+
80
+ export class BridgeDocHandle<T> {
81
+ private snapshot: Snapshot<T>
82
+ private listeners = new Set<() => void>()
83
+ private revision: number | undefined
84
+ private retriesLeft = RETRY_MAX
85
+ private inflight: Promise<void> | undefined
86
+ private readonly ns: string
87
+ private readonly routes: { describe: string; mutate: string }
88
+
89
+ constructor(ns: string) {
90
+ this.ns = ns
91
+ this.routes = settingsBridgeRoutesFor(ns)
92
+ this.snapshot = {
93
+ status: 'loading',
94
+ value: undefined,
95
+ revision: undefined,
96
+ writable: true,
97
+ base: undefined,
98
+ user: undefined,
99
+ mode: 'bridge',
100
+ }
101
+ void this.refresh()
102
+ }
103
+
104
+ getSnapshot(): Snapshot<T> {
105
+ return this.snapshot
106
+ }
107
+
108
+ subscribe(listener: () => void): () => void {
109
+ this.listeners.add(listener)
110
+ return () => {
111
+ this.listeners.delete(listener)
112
+ }
113
+ }
114
+
115
+ async set(field: string, value: unknown): Promise<boolean> {
116
+ return this.mutate([{ op: 'set', path: [field], value }])
117
+ }
118
+
119
+ async unset(field: string): Promise<boolean> {
120
+ return this.mutate([{ op: 'unset', path: [field] }])
121
+ }
122
+
123
+ private notify(): void {
124
+ for (const l of [...this.listeners]) l()
125
+ }
126
+
127
+ private applyDescriptor(d: Descriptor, writable: boolean): void {
128
+ this.revision = typeof d.revision === 'number' ? d.revision : undefined
129
+ this.snapshot = {
130
+ status: 'ready',
131
+ value: descriptorPlainValue(d) as T | undefined,
132
+ revision: this.revision,
133
+ writable,
134
+ base: undefined,
135
+ user: undefined,
136
+ mode: 'bridge',
137
+ }
138
+ }
139
+
140
+ private async request(path: string, body?: unknown): Promise<BridgeBody> {
141
+ const response = await fetch(path, {
142
+ method: 'POST',
143
+ headers: { 'content-type': 'application/json' },
144
+ body: JSON.stringify(body ?? {}),
145
+ })
146
+ if (!response.ok) throw new Error(`bridge ${path} HTTP ${response.status}`)
147
+ return (await response.json()) as BridgeBody
148
+ }
149
+
150
+ /** describe 拉取;瞬态未就绪码在重试预算内延迟重拉,耗尽才落 unavailable。 */
151
+ refresh(): Promise<void> {
152
+ if (this.inflight !== undefined) return this.inflight
153
+ this.inflight = (async () => {
154
+ try {
155
+ const body = await this.request(this.routes.describe)
156
+ if (body.ok && body.value?.descriptor !== undefined) {
157
+ this.retriesLeft = RETRY_MAX
158
+ this.applyDescriptor(body.value.descriptor, body.value.writable !== false)
159
+ } else if (body.code !== undefined && RETRYABLE_CODES.has(body.code) && this.retriesLeft > 0) {
160
+ this.retriesLeft -= 1
161
+ this.inflight = undefined
162
+ setTimeout(() => {
163
+ void this.refresh()
164
+ }, RETRY_DELAY_MS)
165
+ return
166
+ } else {
167
+ this.snapshot = { ...this.snapshot, status: 'unavailable', writable: false }
168
+ }
169
+ } catch {
170
+ // 网络异常:按瞬态处理,预算内重试
171
+ if (this.retriesLeft > 0) {
172
+ this.retriesLeft -= 1
173
+ this.inflight = undefined
174
+ setTimeout(() => {
175
+ void this.refresh()
176
+ }, RETRY_DELAY_MS)
177
+ return
178
+ }
179
+ this.snapshot = { ...this.snapshot, status: 'unavailable', writable: false }
180
+ }
181
+ // 重试路径已在上面清过 inflight(setTimeout 后由 refresh() 重建);
182
+ // 终态路径在此清挂起标记并广播。重试等待期 notify 无害(快照未变)。
183
+ this.inflight = undefined
184
+ this.notify()
185
+ })()
186
+ return this.inflight
187
+ }
188
+
189
+ private async mutate(ops: MutateOp[]): Promise<boolean> {
190
+ const body = await this.request(this.routes.mutate, { ops, expectedRevision: this.revision })
191
+ if (body.ok && body.value?.descriptor !== undefined) {
192
+ this.applyDescriptor(body.value.descriptor, this.snapshot.writable)
193
+ this.notify()
194
+ return true
195
+ }
196
+ if (body.code === 'settings-conflict') {
197
+ await this.refresh() // 乐观并发失败:拉权威值,让下次写入带新 revision
198
+ return false
199
+ }
200
+ throw new Error(`bridge mutate failed: ${body.code ?? 'unknown'} ${body.message ?? ''}`)
201
+ }
202
+ }
@@ -223,6 +223,12 @@ function SelectRow<T extends string>(props: {
223
223
  )
224
224
  }
225
225
 
226
+ /** 卡面字段消毒:宿主 describe 形态异常(≤0.1.5 骨架残留等)时槽崩会殉葬全族
227
+ * tab——ListRow 的 values 非 nullish 非数组(如 {})会让 `?? []` 失守,逐卡硬护栏。 */
228
+ function toList(v: unknown): string[] {
229
+ return Array.isArray(v) ? v.map(String) : []
230
+ }
231
+
226
232
  /** 插件配置卡片主体:可展开的 <li> + header 按钮 + pill switch 字段体。 */
227
233
  export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
228
234
  const snapshot = useSyncExternalStore(
@@ -303,7 +309,7 @@ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
303
309
  <ListRow
304
310
  label="追加官方 provider id"
305
311
  description="精确匹配,优先级最高(逗号分隔)"
306
- values={value.officialProviders ?? []}
312
+ values={toList(value.officialProviders)}
307
313
  placeholder="deepseek-official"
308
314
  disabled={readonly}
309
315
  onCommit={(next) => { void scope.set('officialProviders', next) }}
@@ -311,7 +317,7 @@ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
311
317
  <ListRow
312
318
  label="官方端点名单"
313
319
  description="baseURL 归一化后的 host(逗号分隔)"
314
- values={value.officialBaseURLs ?? []}
320
+ values={toList(value.officialBaseURLs)}
315
321
  placeholder="api.deepseek.com"
316
322
  disabled={readonly}
317
323
  onCommit={(next) => { void scope.set('officialBaseURLs', next) }}
@@ -319,7 +325,7 @@ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
319
325
  <SelectRow<'hold' | 'error'>
320
326
  label="拦截方式"
321
327
  description="挂起等待不报错;或报错并记入延后队列"
322
- value={value.deferredMode ?? 'hold'}
328
+ value={value.deferredMode === 'error' ? 'error' : 'hold'}
323
329
  options={[
324
330
  { value: 'hold', label: '挂起等待(退峰自动放行)' },
325
331
  { value: 'error', label: '报错并延后(退峰续跑)' },
package/src/index.js CHANGED
@@ -24,6 +24,7 @@ import { detectTaskControl } from './detect.js'
24
24
  import { makeIsRoot } from './request-guard.js'
25
25
  import { createWiring } from './wiring.js'
26
26
  import { NS, DEFAULT_SETTINGS, SettingsSchema } from './settings.js'
27
+ import { registerSettingsBridgeRoutes } from './settings-bridge.js'
27
28
 
28
29
  // 0.1.7:声明式设置 —— volatile 字段即自动表单。
29
30
  export const Config = SettingsSchema
@@ -66,10 +67,13 @@ export function apply(ctx, config = {}) {
66
67
  // 否则 client 配置卡报「设置命名空间不可用」。两条 legacy 路径按服务能力
67
68
  // 自动分派:0.1.2/0.1.5 = 服务实例 installSection;0.1.0/0.1.1 = 模块级
68
69
  // installSettingsSection + settingsNamespace。fail-open:失败仅告警。
70
+ /** 宿主 settings 服务活引用(桥的惰性 getter 用;apply 期注入解到即填)。 */
71
+ let settingsSvcRef
69
72
  try {
70
73
  ctx.inject(['settings'], (sctx) => {
71
74
  const service = sctx.settings
72
75
  if (service === undefined || service === null) return
76
+ settingsSvcRef = service
73
77
  const hooks = {
74
78
  setSource: (source) => {
75
79
  configSource = typeof source === 'function' ? source() : source
@@ -460,4 +464,16 @@ export function apply(ctx, config = {}) {
460
464
  }
461
465
  }, 'session-guard: routes')
462
466
  }
467
+
468
+ // ── settings webServer 桥(M4/T20-b 泛化):describe/mutate 两端点 ──────
469
+ // 双轨数据源的桥轨:≤0.1.5(settingsScope 死路/无服务)时 client
470
+ // BridgeDocHandle 经此读写宿主 settings 服务。getSettingsSvc 惰性取
471
+ // (cordis ctx.get 永不 throw)——宿主 settings 解析早晚不影响注册。
472
+ // 置于 prefix 路由之后(routes[0] = /session-guard 前缀,契约与测试锚定)。
473
+ // fail-open:注册失败 error 告警(对齐 L474 风格),不炸插件加载。
474
+ try {
475
+ registerSettingsBridgeRoutes(ctx.webServer, () => settingsSvcRef, NS)
476
+ } catch (error) {
477
+ ctx.logger?.error?.(`[session-guard] settings bridge registration failed: ${String(error && error.message || error)}`)
478
+ }
463
479
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * dsh-session-guard — settings webServer 桥(M4/T20-b 泛化)。
3
+ *
4
+ * 设置卡双轨数据源的桥轨:≤0.1.5(settingsScope fiber 永不解 / 0.1.0-0.1.1 无
5
+ * client 服务)时,client BridgeDocHandle 经 describe/mutate 两端点读写宿主
6
+ * settings 服务。桥只是 HTTP 皮:读 = svc.describe 取自家 ns 描述符;写 =
7
+ * svc.mutate 转 ops 落宿主服务(不落第二份状态);mutate 成功即 describe 回填,
8
+ * 带 expectedRevision 乐观并发。守卫:POST-only + redactSecrets + 256KB。
9
+ * 语义与 perm-gate src/bridge.ts(5260fe6 泛化)逐字对齐,本仓为 JS 形态。
10
+ */
11
+
12
+ /** 一个 settings ns 的桥路由对——client 侧(src/client/settings-bridge.ts)必须同式派生(测试钉死一致)。 */
13
+ function settingsBridgeRoutesFor(ns) {
14
+ return { describe: `/api/${ns}/settings/describe`, mutate: `/api/${ns}/settings/mutate` }
15
+ }
16
+
17
+ function readJsonBody(req, limit = 256 * 1024) {
18
+ return new Promise((resolve, reject) => {
19
+ const chunks = []
20
+ let size = 0
21
+ req.on('data', (chunk) => {
22
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
23
+ size += buf.length
24
+ if (size > limit) {
25
+ reject(new Error('bridge body too large'))
26
+ if (typeof req.resume === 'function') req.resume()
27
+ return
28
+ }
29
+ chunks.push(buf)
30
+ })
31
+ req.on('end', () => {
32
+ const raw = Buffer.concat(chunks).toString('utf8')
33
+ if (raw === '') {
34
+ resolve({})
35
+ return
36
+ }
37
+ try {
38
+ resolve(JSON.parse(raw))
39
+ } catch (e) {
40
+ reject(e instanceof Error ? e : new Error(String(e)))
41
+ }
42
+ })
43
+ req.on('error', reject)
44
+ })
45
+ }
46
+
47
+ function sendJson(res, code, body) {
48
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
49
+ res.end(JSON.stringify(body))
50
+ }
51
+
52
+ function isConflict(e) {
53
+ return e instanceof Error && /conflict|revision/i.test(e.message)
54
+ }
55
+
56
+ function describeNs(svc, ns) {
57
+ return svc.describe({ redactSecrets: true }).find((d) => d.ns === ns)
58
+ }
59
+
60
+ /**
61
+ * 注册 describe/mutate 两端点(kind:'exact')。
62
+ * @param {unknown} server 宿主 webServer 服务(须有 register(kind,path,handler))
63
+ * @param {() => unknown} getSettingsSvc 惰性取宿主 settings 服务(cordis ctx.get 永不 throw,可安全常取)
64
+ * @param {string} ns 自家 settings 命名空间
65
+ * @returns {Array<() => void>} 注销函数数组
66
+ */
67
+ export function registerSettingsBridgeRoutes(server, getSettingsSvc, ns) {
68
+ if (typeof server !== 'object' || server === null) return []
69
+ const ws = typeof server.register === 'function' ? server : undefined
70
+ if (ws === undefined) return []
71
+ const offs = []
72
+ const routes = settingsBridgeRoutesFor(ns)
73
+
74
+ offs.push(ws.register({
75
+ kind: 'exact',
76
+ path: routes.describe,
77
+ handler: (req, res) => {
78
+ if (req.method !== 'POST') {
79
+ sendJson(res, 405, { ok: false, code: 'method-not-allowed' })
80
+ return
81
+ }
82
+ const svc = getSettingsSvc()
83
+ if (svc === undefined || svc === null || typeof svc.describe !== 'function') {
84
+ sendJson(res, 200, { ok: false, code: 'settings-unavailable', message: 'settings service not resolved yet' })
85
+ return
86
+ }
87
+ try {
88
+ const descriptor = describeNs(svc, ns)
89
+ if (descriptor === undefined) {
90
+ sendJson(res, 200, { ok: false, code: 'ns-not-registered', message: `settings namespace "${ns}" is not registered` })
91
+ return
92
+ }
93
+ sendJson(res, 200, { ok: true, value: { descriptor, writable: svc.writable !== false } })
94
+ } catch (e) {
95
+ sendJson(res, 200, { ok: false, code: 'internal', message: e instanceof Error ? e.message : String(e) })
96
+ }
97
+ },
98
+ }))
99
+
100
+ offs.push(ws.register({
101
+ kind: 'exact',
102
+ path: routes.mutate,
103
+ handler: (req, res) => {
104
+ if (req.method !== 'POST') {
105
+ sendJson(res, 405, { ok: false, code: 'method-not-allowed' })
106
+ return
107
+ }
108
+ const svc = getSettingsSvc()
109
+ if (svc === undefined || svc === null || typeof svc.mutate !== 'function') {
110
+ sendJson(res, 200, { ok: false, code: 'settings-unavailable', message: 'settings service mutate not available' })
111
+ return
112
+ }
113
+ readJsonBody(req).then((body) => {
114
+ const ops = body.ops
115
+ const valid = Array.isArray(ops) && ops.every((op) => op !== null && typeof op === 'object' && (op.op === 'set' || op.op === 'unset'))
116
+ if (!valid) {
117
+ sendJson(res, 200, { ok: false, code: 'settings-rejected', message: 'malformed bridge mutate ops' })
118
+ return
119
+ }
120
+ const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
121
+ svc.mutate(ns, ops, expectedRevision).then(() => {
122
+ // mutate 成功即 describe 回填(写路径归口宿主服务,无第二份状态)
123
+ const descriptor = describeNs(svc, ns)
124
+ if (descriptor === undefined) {
125
+ sendJson(res, 200, { ok: false, code: 'internal', message: `namespace "${ns}" disposed after mutate` })
126
+ return
127
+ }
128
+ sendJson(res, 200, { ok: true, value: { descriptor } })
129
+ }).catch((e) => {
130
+ sendJson(res, 200, isConflict(e)
131
+ ? { ok: false, code: 'settings-conflict', message: e instanceof Error ? e.message : String(e) }
132
+ : { ok: false, code: 'internal', message: e instanceof Error ? e.message : String(e) })
133
+ })
134
+ }).catch((e) => {
135
+ sendJson(res, 200, { ok: false, code: 'bad-request', message: e instanceof Error ? e.message : String(e) })
136
+ })
137
+ },
138
+ }))
139
+
140
+ return offs
141
+ }