dsh-session-guard 0.1.0

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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * dsh-session-guard — 浏览器 half。
3
+ *
4
+ * 职责(全部 fail-open,D8):
5
+ * - 在 composer 输入区右侧注册一个**纯展示**状态徽标(高峰/谷时/周末),轮询
6
+ * /session-guard/status;
7
+ * - 注册 `settings.plugin.item` 设置卡片,经 `settingsScope.bind({ namespace })`
8
+ * 绑定 host 已注册的 `session-guard` 命名空间——这正是“插件配置”面板显示本
9
+ * 插件的**必要**机制(对齐 dsh-thinking-levels / dsh-context);
10
+ * - **不做**冻结/会话动作——冻结按钮由 input-traffic 接管并经 /session-guard/rpc
11
+ * 桥接 host 会话门;本插件客户端不注册任何按钮,避免与 input-traffic 冲突。
12
+ *
13
+ * 构建:tsdown → lib/client.js(__ModuleLoader__.load 注册,与 input-traffic 同构)。
14
+ */
15
+ import { StatusBadge } from './status-badge'
16
+ import { SessionGuardCard } from './settings-card'
17
+
18
+ /** 客户端所需服务:slots(状态徽标 + 设置卡片)+ locale + settingsScope(设置卡片绑定)。 */
19
+ export const inject = ['slots', 'locale', 'settingsScope']
20
+
21
+ /** 轻量 ctx 类型(仅本客户端用到的方法;构建时类型被剥离)。 */
22
+ interface SlotsFace {
23
+ inject: (_name: string, _fn: () => unknown) => () => void
24
+ register: (_options: Record<string, unknown>, _component: unknown) => () => void
25
+ }
26
+ interface SettingsScopeFace {
27
+ bind: (_spec: { namespace: string }) => unknown
28
+ }
29
+ interface ClientCtx {
30
+ slots: SlotsFace
31
+ settingsScope: SettingsScopeFace
32
+ }
33
+
34
+ /** host 侧 src/settings.js 注册的命名空间(保持一致)。 */
35
+ const NS = 'session-guard'
36
+
37
+ export function apply(ctx: ClientCtx) {
38
+ // 状态徽标:输入区右侧,纯展示。
39
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
40
+ name: 'conversation.input.right',
41
+ id: 'session-guard-status',
42
+ order: 50,
43
+ locale: 'session-guard',
44
+ }, StatusBadge))
45
+
46
+ // 插件配置卡片:绑定 session-guard 命名空间,经 settingsScope 渲染并提交。
47
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
48
+ name: 'settings.plugin.item',
49
+ id: NS,
50
+ key: NS,
51
+ locale: 'session-guard',
52
+ inject: () => ({
53
+ scope: ctx.settingsScope.bind({ namespace: NS }),
54
+ }),
55
+ }, SessionGuardCard))
56
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * dsh-session-guard — 插件配置卡片(settings.plugin.item 面)。
3
+ *
4
+ * 对齐 dsh-thinking-levels / dsh-tidychat 的卡片语法:一个可展开的 `<li>`,
5
+ * header 按钮(插件名 + 描述 + chevron)切换字段体;开关为 pill switch
6
+ * (track + thumb),不是复选框对勾。
7
+ *
8
+ * 通过 `settingsScope.bind({ namespace: NS })` 绑定 host 已注册的
9
+ * `session-guard` 命名空间;每次变更立即经 scope 提交(无 staged form)。
10
+ * 仅依赖 react;CSS 经 `<style data-plugin-css>` 注入一次,控件为原生 HTML,
11
+ * 客户端 bundle 无 value-import @deepseek-ai/* 平台包(类型导入被构建擦除)。
12
+ */
13
+ import { useState, useSyncExternalStore } from 'react'
14
+ import type { JSX } from 'react'
15
+ import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
16
+
17
+ /** 命名空间值形状(与 host 的 DEFAULT_SETTINGS 对应)。 */
18
+ export interface SessionGuardConfig {
19
+ enabled: boolean
20
+ offPeakAutoResume: boolean
21
+ weekendMode: boolean
22
+ queueFallback: boolean
23
+ retryEnabled: boolean
24
+ timezone: string
25
+ pauseMode: 'safe' | 'force'
26
+ pauseReason: 'wait' | 'stop'
27
+ }
28
+
29
+ /** 注入给卡片的面:绑定到 session-guard 命名空间的 settings scope。 */
30
+ export interface SettingsCardInjected {
31
+ scope: SettingsScope<SessionGuardConfig>
32
+ }
33
+
34
+ export type SettingsCardProps = SettingsCardInjected
35
+
36
+ /** 卡片样式,注入一次(保持 bundle CSS-free)。 */
37
+ const CARD_CSS = `
38
+ .sgCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
39
+ .sgCard:hover{border-color:var(--dsw-alias-label-dimmed)}
40
+ .sgCard-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
41
+ .sgCardHeader{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:transparent;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
42
+ .sgCardHeadtext{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
43
+ .sgCardName{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
44
+ .sgCardDesc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
45
+ .sgCardChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}
46
+ .sgCardChevron-open{transform:rotate(180deg)}
47
+ .sgCardBody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:4px 0 12px}
48
+ .sgRow{border-bottom:1px solid var(--dsw-alias-border-l2);align-items:center;gap:8px;padding:16px 0;display:flex}
49
+ .sgRow:last-child{border-bottom:0}
50
+ .sgRowText{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex}
51
+ .sgTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
52
+ .sgDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
53
+ .sgSwitch{position:relative;width:40px;height:22px;flex:none}
54
+ .sgSwitch>input{position:absolute;inset:0;width:100%;height:100%;opacity:0;margin:0;cursor:pointer}
55
+ .sgSwitch>input:disabled{cursor:not-allowed}
56
+ .sgSwitchTrack{position:absolute;inset:0;border-radius:22px;background:var(--dsw-alias-interactive-bg-hover);transition:background .16s}
57
+ .sgSwitch>input:checked+.sgSwitchTrack{background:var(--dsw-alias-button-primary-fill)}
58
+ .sgSwitchThumb{position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .16s}
59
+ .sgSwitch>input:checked~.sgSwitchThumb{transform:translateX(18px)}
60
+ .sgReadonly{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:8px 0 0}
61
+ `
62
+
63
+ /** 注入一次卡片样式。 */
64
+ function injectCss(): void {
65
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="session-guard-card"]') === null) {
66
+ const tag = document.createElement('style')
67
+ tag.dataset.plugin = 'dsh-session-guard'
68
+ tag.dataset.pluginCss = 'session-guard-card'
69
+ tag.textContent = CARD_CSS
70
+ document.head.appendChild(tag)
71
+ }
72
+ }
73
+
74
+ /** 一行 pill switch(滑块开关,绑定 scope)。 */
75
+ function SwitchRow(props: {
76
+ label: string
77
+ description?: string
78
+ checked: boolean
79
+ disabled: boolean
80
+ onChange: (_next: boolean) => void
81
+ }): JSX.Element {
82
+ const { label, description, checked, disabled, onChange } = props
83
+ return (
84
+ <div className="sgRow">
85
+ <div className="sgRowText">
86
+ <div className="sgTitle">{label}</div>
87
+ {description !== undefined && <div className="sgDesc">{description}</div>}
88
+ </div>
89
+ <label className="sgSwitch">
90
+ <input
91
+ type="checkbox"
92
+ checked={checked}
93
+ disabled={disabled}
94
+ onChange={(e) => onChange(e.currentTarget.checked)}
95
+ />
96
+ <span className="sgSwitchTrack" />
97
+ <span className="sgSwitchThumb" />
98
+ </label>
99
+ </div>
100
+ )
101
+ }
102
+
103
+ /** 插件配置卡片主体:可展开的 <li> + header 按钮 + pill switch 字段体。 */
104
+ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
105
+ const snapshot = useSyncExternalStore(
106
+ (listener) => scope.subscribe(listener),
107
+ () => scope.getSnapshot(),
108
+ )
109
+ const unavailable = snapshot.status === 'unavailable'
110
+ const readonly = unavailable || !snapshot.writable
111
+ const value = (snapshot.value ?? {}) as Partial<SessionGuardConfig>
112
+ const [open, setOpen] = useState(false)
113
+
114
+ injectCss()
115
+
116
+ const toggle = (field: keyof SessionGuardConfig, next: boolean) => { void scope.set(field, next) }
117
+
118
+ return (
119
+ <li className={'sgCard' + (open ? ' sgCard-open' : '')}>
120
+ <button
121
+ type="button"
122
+ className="sgCardHeader"
123
+ aria-expanded={open}
124
+ onClick={() => setOpen(!open)}
125
+ >
126
+ <span className="sgCardHeadtext">
127
+ <span className="sgCardName">会话守护门禁</span>
128
+ <span className="sgCardDesc">高峰自动暂停运行会话,周末模式无视峰谷畅快跑</span>
129
+ </span>
130
+ <svg
131
+ className={'sgCardChevron' + (open ? ' sgCardChevron-open' : '')}
132
+ viewBox="0 0 14 14"
133
+ width={14}
134
+ height={14}
135
+ fill="none"
136
+ aria-hidden="true"
137
+ >
138
+ <path d="M3.5 5.5L7 9l3.5-3.5" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
139
+ </svg>
140
+ </button>
141
+ {open && (
142
+ <div className="sgCardBody">
143
+ {unavailable ? (
144
+ <p style={{ margin: '0', padding: '12px 0', fontSize: '13px', color: 'var(--dsw-alias-label-tertiary)' }}>
145
+ 设置命名空间不可用:请确认 dsh-session-guard 已装配进此 profile。
146
+ </p>
147
+ ) : (
148
+ <>
149
+ <SwitchRow
150
+ label="高峰自动暂停冻结会话"
151
+ description="高峰时段自动暂停运行会话"
152
+ checked={value.enabled ?? true}
153
+ disabled={readonly}
154
+ onChange={(next) => toggle('enabled', next)}
155
+ />
156
+ <SwitchRow
157
+ label="低谷自动恢复"
158
+ description="低峰时段自动恢复被暂停的会话"
159
+ checked={value.offPeakAutoResume ?? true}
160
+ disabled={readonly}
161
+ onChange={(next) => toggle('offPeakAutoResume', next)}
162
+ />
163
+ <SwitchRow
164
+ label="周末模式"
165
+ description="识别周末,无视峰谷畅快跑"
166
+ checked={value.weekendMode ?? true}
167
+ disabled={readonly}
168
+ onChange={(next) => toggle('weekendMode', next)}
169
+ />
170
+ <SwitchRow
171
+ label="回退锁队列"
172
+ description="无会话门时锁等待队列"
173
+ checked={value.queueFallback ?? true}
174
+ disabled={readonly}
175
+ onChange={(next) => toggle('queueFallback', next)}
176
+ />
177
+ <SwitchRow
178
+ label="自动重试"
179
+ description="后端重试,默认关(保守)"
180
+ checked={value.retryEnabled ?? false}
181
+ disabled={readonly}
182
+ onChange={(next) => toggle('retryEnabled', next)}
183
+ />
184
+ {!snapshot.writable && <p className="sgReadonly">当前只读,无法修改。</p>}
185
+ </>
186
+ )}
187
+ </div>
188
+ )}
189
+ </li>
190
+ )
191
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * dsh-session-guard — 状态徽标(纯展示,fail-open)。
3
+ *
4
+ * 轮询 host 的 /session-guard/status(全局当前阶段),显示 高峰/谷时/周末。
5
+ * 仅展示,不做任何队列/会话动作;冻结按钮由 input-traffic 经桥接管(D6/D8)。
6
+ */
7
+ import { useEffect, useState } from 'react'
8
+
9
+ /** slot 运行时注入的会话级 props(sessionId 由 dsh 的 SessionStandardProps 提供)。 */
10
+ export interface StatusBadgeProps {
11
+ sessionId?: string
12
+ }
13
+
14
+ /** /session-guard/status 返回的全局阶段。 */
15
+ export interface Status {
16
+ phase: 'peak' | 'off-peak' | 'weekend'
17
+ enabled: boolean
18
+ weekendMode: boolean
19
+ timezone: string
20
+ }
21
+
22
+ const LABELS = {
23
+ peak: '高峰',
24
+ 'off-peak': '谷时',
25
+ weekend: '周末',
26
+ } as const
27
+
28
+ const POLL_MS = 15_000
29
+
30
+ /** 状态徽标:轮询全局阶段,显示 高峰/谷时/周末(enabled 关闭或请求失败时静默隐藏)。 */
31
+ export function StatusBadge({ sessionId }: StatusBadgeProps) {
32
+ const [status, setStatus] = useState<Status | null>(null)
33
+
34
+ useEffect(() => {
35
+ if (!sessionId) return
36
+ let cancelled = false
37
+ const poll = async () => {
38
+ try {
39
+ const res = await fetch('/session-guard/status')
40
+ const body = await res.json().catch(() => null)
41
+ if (!cancelled && body?.ok && body.status) setStatus(body.status)
42
+ } catch {
43
+ // fail-open:路由/网络不可达 → 静默,徽标隐藏。
44
+ }
45
+ }
46
+ void poll()
47
+ const timer = setInterval(poll, POLL_MS)
48
+ return () => {
49
+ cancelled = true
50
+ clearInterval(timer)
51
+ }
52
+ }, [sessionId])
53
+
54
+ if (!status || !status.enabled) return null
55
+
56
+ const cls = status.phase === 'peak' ? 'sg-peak' : status.phase === 'weekend' ? 'sg-weekend' : 'sg-off'
57
+ return (
58
+ <span
59
+ className={`sg-status ${cls}`}
60
+ title={`${LABELS[status.phase]} · ${status.timezone}${status.weekendMode ? ' · 周末模式' : ''}`}
61
+ data-sg-phase={status.phase}
62
+ >
63
+ {LABELS[status.phase]}
64
+ </span>
65
+ )
66
+ }
package/src/detect.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * dsh-session-guard — 自动检测(D7,诚实边界)。
3
+ *
4
+ * - host 可自动探测:`ctx.get('taskControl')`(会话门)、`ctx.get('sessionGuard')`。
5
+ * - client 可自动探测:input-traffic 补的「最小桥」标记 `window.__DSH_SESSION_GUARD_BRIDGE__`
6
+ * (方案 A 中 input-traffic 冻结按钮触发时会调 sessionGuard.stopNextTurn)。
7
+ * 探测不到 → 本插件客户端使用自带回退冻结(fallback),fail-open 不报错(D8)。
8
+ */
9
+
10
+ /** host:会话门(dsh-task-control)是否可用。 */
11
+ export function detectTaskControl(ctx) {
12
+ return !!(ctx && typeof ctx.get === 'function' && ctx.get('taskControl'))
13
+ }
14
+
15
+ /** host:sessionGuard 服务是否已提供(其他插件探测用)。 */
16
+ export function detectSessionGuard(ctx) {
17
+ return !!(ctx && typeof ctx.get === 'function' && ctx.get('sessionGuard'))
18
+ }
19
+
20
+ /**
21
+ * client:input-traffic 最小桥是否已注册。
22
+ * @param {object} [g] 显式传入全局对象(便于测试);默认 globalThis
23
+ */
24
+ export function detectInputTrafficBridge(g) {
25
+ const root = g || (typeof globalThis !== 'undefined' ? globalThis : {})
26
+ return !!(root && root.__DSH_SESSION_GUARD_BRIDGE__ && typeof root.__DSH_SESSION_GUARD_BRIDGE__.stopNextTurn === 'function')
27
+ }
package/src/gate.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * dsh-session-guard — 会话门驱动(D3/D5/D6,自研真实锁定)。
3
+ *
4
+ * **主路径**(替换 dsh-task-control):经自研 `pauseGate` 真暂停 session 推进——
5
+ * `pauseGate.pause(id,{mode,reason})` 调用 agent.cancel / goals.pause / session/event
6
+ * 安全边界落地(见 src/pause-gate.js),不再依赖 `ctx.get('taskControl')`,也不再退化成
7
+ * 只写 `queueLocked` 标记。
8
+ *
9
+ * **降级路径**(fail-open,D8):仅当自研暂停因 agent 不可用而失败时,按 `queueFallback`
10
+ * 回退为只锁等待队列(`queueLocked` 持久化标记,供前端/重试让路)。
11
+ *
12
+ * resume 对称:自研 resume(从暂停点续跑)或清 queueLock。
13
+ */
14
+ import { idleState } from './store.js'
15
+
16
+ /**
17
+ * @param {object} deps
18
+ * @param {()=>object|null} deps.getCtx 取当前 host ctx(懒,便于测试)
19
+ * @param {()=>object} deps.getSettings 读实时配置(pauseMode/pauseReason)
20
+ * @param {ReturnType<import('./store.js').createStore>} deps.store 持久化队列锁
21
+ * @param {ReturnType<import('./pause-gate.js').createPauseGate>} [deps.pauseGate] 自研会话门引擎
22
+ * @param {(event:string, sessionId:string, payload?:object)=>void} [deps.emit] 状态变化通知
23
+ */
24
+ export function createGate({ getCtx: _getCtx, getSettings, store, pauseGate, emit }) {
25
+ function lockQueue(sessionId, reason) {
26
+ const cur = store.get(sessionId)
27
+ const next = { ...(cur || idleState(sessionId)), queueLocked: true, lockReason: reason, updatedAt: Date.now() }
28
+ store.set(sessionId, next)
29
+ if (emit) emit('queue-lock', sessionId, { reason })
30
+ return next
31
+ }
32
+
33
+ function unlockQueue(sessionId) {
34
+ const cur = store.get(sessionId)
35
+ const next = { ...(cur || idleState(sessionId)), queueLocked: false, lockReason: null, updatedAt: Date.now() }
36
+ store.set(sessionId, next)
37
+ if (emit) emit('queue-unlock', sessionId)
38
+ return next
39
+ }
40
+
41
+ /**
42
+ * 停掉 session 的下一回合(真实锁定推进)。
43
+ * ① 自研 pauseGate.pause(真暂停);② 自研失败/agent 不可用 → 按 queueFallback 降级锁队列。
44
+ * @param {string} sessionId
45
+ * @param {object} [opts] { mode, reason }
46
+ */
47
+ async function stopNextTurn(sessionId, opts = {}) {
48
+ const cfg = getSettings()
49
+ if (pauseGate) {
50
+ try {
51
+ const mode = opts.mode ?? cfg.pauseMode ?? 'safe'
52
+ const reason = opts.reason ?? cfg.pauseReason ?? 'wait'
53
+ const r = pauseGate.pause(sessionId, { mode, reason })
54
+ if (r && r.kind === 'success') return { ok: true, via: 'pauseGate', result: r }
55
+ // kind === 'error'(如 no live agent)→ 落到降级,语义如实上报。
56
+ } catch {
57
+ // 自研内部异常 → fail-open 降级,绝不中断冻结动作。
58
+ }
59
+ }
60
+ // 无自研门或自研失败 → 回退只锁等待队列(自带实现,D5)。
61
+ if (cfg.queueFallback === false) {
62
+ return { ok: false, via: 'queueLock', error: 'queueFallback disabled' }
63
+ }
64
+ lockQueue(sessionId, 'peak')
65
+ return { ok: true, via: 'queueLock' }
66
+ }
67
+
68
+ /**
69
+ * 恢复 session 推进(从暂停点续跑)。
70
+ * ① 自研 resume(confirm + choice);② 降级清 queueLock。
71
+ */
72
+ async function resume(sessionId, opts = {}) {
73
+ if (pauseGate) {
74
+ try {
75
+ const r = pauseGate.resume(sessionId, { confirm: true, choice: opts.choice ?? 'rerun' })
76
+ if (r && r.kind === 'success') return { ok: true, via: 'pauseGate', result: r }
77
+ } catch {
78
+ // 降级清队列锁。
79
+ }
80
+ }
81
+ unlockQueue(sessionId)
82
+ return { ok: true, via: 'queueLock' }
83
+ }
84
+
85
+ return {
86
+ stopNextTurn,
87
+ resume,
88
+ lockQueue,
89
+ unlockQueue,
90
+ /** 自研会话门是否可用(自研后恒视为可用;供 detect/UI 展示)。 */
91
+ taskControlAvailable: () => (pauseGate ? pauseGate.taskControlAvailable() : false),
92
+ }
93
+ }