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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -1,293 +1,405 @@
1
- /**
2
- * dsh-session-guard — host half。
3
- *
4
- * 高峰自动会话门:
5
- * - 每 30s tick 判定状态(NORMAL ↔ PAUSED_PEAK),纯状态机见 scheduler.js。
6
- * - 入峰(且非周末):对所有 running root session 调 gate.stopNextTurn
7
- * (有 taskControl → 会话门 safe+wait;无 → 回退锁等待队列)。
8
- * - 退峰/周末:gate.resume 全部。
9
- * - `ctx.provide('sessionGuard')` 冗余端口,input-traffic 冻结按钮透传接入。
10
- * - 设置:设置 → 插件 → session-guard 子板块,简单开关(enabled / weekendMode / queueFallback)。
11
- * schemastery 为常规 dependency,设置经 `ctx.inject(['settings'])` 本地接口注册(src/settings.js,
12
- * 对齐 dsh-thinking-levels;不 value-import dsh-settings);设置服务缺失时 fail-open 用默认配置照常运行。
13
- */
14
- import { computeState, transition } from './scheduler.js'
15
- import { wallClock, isWeekend, isInPeak, BILLING_TIMEZONE } from './time.js'
16
- import { createStore } from './store.js'
17
- import { createGate } from './gate.js'
18
- import { createBridge } from './bridge.js'
19
- import { createRetry } from './retry.js'
20
- import { createPauseStore } from './pause-store.js'
21
- import { createPauseGate } from './pause-gate.js'
22
- import { detectTaskControl } from './detect.js'
23
- import { NS, DEFAULT_SETTINGS, SettingsSchema, registerSettings } from './settings.js'
24
-
25
- export const name = 'session-guard'
26
- export const inject = ['agents', 'webServer', 'settings', 'timer', 'commands', 'goals']
27
-
28
- export { NS, DEFAULT_SETTINGS }
29
-
30
- export function apply(ctx) {
31
- const store = createStore()
32
- let lastState = null
33
-
34
- /** 读实时设置(settings 服务不可用时回退默认)。 */
35
- function readCfg() {
36
- try {
37
- const v = ctx.settings.get(NS)
38
- return v && typeof v === 'object' ? { ...DEFAULT_SETTINGS, ...v } : { ...DEFAULT_SETTINGS }
39
- } catch {
40
- return { ...DEFAULT_SETTINGS }
41
- }
42
- }
43
-
44
- // ── 自研会话门(脱离 dsh-task-control,真暂停)──
45
- // 状态持久化 + 引擎;经 gate.stopNextTurn/resume 主路径接入;/pause /resume /cancel 命令。
46
- const pauseStore = createPauseStore()
47
- const pauseGate = createPauseGate({ ctx, pauseStore })
48
- const gate = createGate({ getCtx: () => ctx, getSettings: readCfg, store, pauseGate })
49
- const bridge = createBridge(ctx, gate, store, pauseGate)
50
-
51
- // ── 冗余端口:input-traffic 冻结按钮透传接入(D5/D6/D8)──
52
- ctx.provide('sessionGuard', bridge)
53
-
54
- // ── 自研会话门:安全边界监听(session/event 落地延迟暂停)──
55
- // 监听在命令注册之前,让任何会话事件都能在安全边界落地 pending pause。
56
- if (typeof ctx.on === 'function') {
57
- ctx.effect(() => ctx.on('session/event', (session, event) => {
58
- try {
59
- pauseGate.handleEvent(session, event)
60
- } catch (e) {
61
- ctx.logger?.warn?.('[session-guard] session event handling failed: ' + String(e))
62
- }
63
- }), 'session-guard: pause-gate events')
64
- }
65
-
66
- // ── 手动会话门命令(/pause /resume /cancel,全量移植)──
67
- if (typeof ctx.commands?.register === 'function') {
68
- const tokensOf = (rawInput) => String(rawInput ?? '').trim().split(/\s+/).filter(Boolean)
69
- ctx.effect(() => ctx.commands.register({
70
- name: 'pause',
71
- description: 'pause the running task (safe: defers to the safe boundary; force: interrupts tools and reasoning now; wait: let reasoning finish; bare /pause follows the pause settings)',
72
- input: { hint: '[force|safe] [stop|wait]' },
73
- handler: (invocation) => {
74
- const sid = String(invocation?.agent?.id ?? '')
75
- if (!sid) return { kind: 'error', text: 'no session for this command' }
76
- const opts = {}
77
- for (const t of tokensOf(invocation.rawInput)) {
78
- if (t === 'force' || t === 'safe') opts.mode = t
79
- if (t === 'stop' || t === 'wait') opts.reason = t
80
- }
81
- return pauseGate.pause(sid, opts)
82
- },
83
- }))
84
- ctx.effect(() => ctx.commands.register({
85
- name: 'resume',
86
- description: 'resume the paused task and continue from the pause point (a force-paused task with an interrupted tool needs `confirm`, plus `rerun`/`skip` for the tool)',
87
- input: { hint: '[confirm] [rerun|skip]' },
88
- handler: (invocation) => {
89
- const sid = String(invocation?.agent?.id ?? '')
90
- if (!sid) return { kind: 'error', text: 'no session for this command' }
91
- const tokens = tokensOf(invocation.rawInput)
92
- return pauseGate.resume(sid, {
93
- confirm: tokens.includes('confirm'),
94
- choice: tokens.includes('skip') ? 'skip' : 'rerun',
95
- })
96
- },
97
- }))
98
- ctx.effect(() => ctx.commands.register({
99
- name: 'cancel',
100
- description: 'cancel the running task (stops the current turn immediately, keeps the queue)',
101
- handler: (invocation) => {
102
- const sid = String(invocation?.agent?.id ?? '')
103
- if (!sid) return { kind: 'error', text: 'no session for this command' }
104
- return pauseGate.cancel(sid)
105
- },
106
- }))
107
- }
108
-
109
- // ── 后端自动重试(D9):冻结/门控期间让路,绝不绕过会话门 ──
110
- createRetry({
111
- ctx,
112
- getSettings: readCfg,
113
- isFrozen: (sessionId) => {
114
- const st = bridge.state(sessionId)
115
- if (st.queueLocked) return true
116
- if (st.paused) return true // 自研会话门真暂停
117
- if (st.taskControl && st.taskControl.paused) return true
118
- return false
119
- },
120
- })
121
-
122
- // ── 设置子板块(设置 → 插件 → session-guard,简单开关)──
123
- // fail-open:原生设置栈可用才注册,缺失则静默降级用默认配置(永不因设置依赖而崩)。
124
- void registerSettings(ctx)
125
-
126
- // ── 状态机驱动(30s tick)──
127
- async function onEnterPeak(cfg) {
128
- const agents = ctx.agents
129
- const roots = typeof agents.roots === 'function' ? agents.roots() : agents.list()
130
- const paused = []
131
- for (const agent of roots) {
132
- if (agent && agent.status === 'running') {
133
- const r = await gate.stopNextTurn(String(agent.id), {
134
- mode: cfg.pauseMode,
135
- reason: cfg.pauseReason,
136
- })
137
- paused.push({ sessionId: String(agent.id), via: r.via, ok: r.ok })
138
- }
139
- }
140
- ctx.logger?.info?.(`[session-guard] peak entered — paused ${paused.length} running session(s): ${JSON.stringify(paused)}`)
141
- }
142
-
143
- async function onLeavePeak(cfg) {
144
- // 低谷自动恢复开关:关掉则退峰不自动恢复(会话保持暂停,需手动恢复)。
145
- if (cfg.offPeakAutoResume === false) {
146
- ctx.logger?.info?.('[session-guard] off-peak auto-resume disabled — sessions stay paused')
147
- return
148
- }
149
- const agents = ctx.agents
150
- const roots = typeof agents.roots === 'function' ? agents.roots() : agents.list()
151
- const resumed = []
152
- for (const agent of roots) {
153
- const r = await gate.resume(String(agent.id), { choice: 'rerun' })
154
- resumed.push({ sessionId: String(agent.id), via: r.via, ok: r.ok })
155
- }
156
- ctx.logger?.info?.(`[session-guard] peak left — resumed ${resumed.length} session(s): ${JSON.stringify(resumed)}`)
157
- }
158
-
159
- function tick() {
160
- try {
161
- const cfg = readCfg()
162
- const next = computeState(cfg, new Date())
163
- if (lastState === null) {
164
- lastState = next
165
- return // 首次 tick 只记录基线,不触发(避免装插件瞬间误暂停)
166
- }
167
- const t = transition(lastState, next)
168
- if (t.enter) {
169
- lastState = { ...next }
170
- void onEnterPeak(cfg)
171
- } else if (t.leave) {
172
- lastState = { ...next }
173
- void onLeavePeak(cfg)
174
- } else {
175
- lastState = next
176
- }
177
- } catch (e) {
178
- ctx.logger?.error?.(`[session-guard] tick failed: ${String(e && e.message || e)}`)
179
- }
180
- }
181
-
182
- ctx.effect(() => ctx.timer.interval(tick, 30_000), 'session-guard: tick')
183
- tick()
184
-
185
- // ── HTTP 路由 ──
186
- if (ctx.webServer && typeof ctx.webServer.register === 'function') {
187
- ctx.effect(() => ctx.webServer.register({
188
- kind: 'prefix',
189
- path: '/session-guard',
190
- handler: async (req, res) => {
191
- try {
192
- const url = new URL(req.url ?? '/', 'http://dsh.local')
193
- const method = req.method ?? 'GET'
194
- const json = (code, payload) => {
195
- res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
196
- res.end(JSON.stringify(payload))
197
- }
198
- // GET /session-guard/state?session=<id>
199
- if (method === 'GET' && url.pathname === '/session-guard/state') {
200
- const sessionId = url.searchParams.get('session') ?? ''
201
- if (!sessionId) return json(400, { ok: false, error: 'missing session' })
202
- return json(200, { ok: true, state: bridge.state(sessionId) })
203
- }
204
- // GET /session-guard/settings
205
- if (method === 'GET' && url.pathname === '/session-guard/settings') {
206
- const cfg = readCfg()
207
- return json(200, { ok: true, settings: cfg, taskControlAvailable: detectTaskControl(ctx) })
208
- }
209
- // GET /session-guard/status —— 全局当前阶段(状态徽标轮询用)
210
- // 峰谷判定:固定北京时间(BILLING_TIMEZONE),与 DeepSeek 官方计费一致
211
- // 周末判定:用配置时区(用户本地的周末)
212
- if (method === 'GET' && url.pathname === '/session-guard/status') {
213
- const cfg = readCfg()
214
- const now = new Date()
215
- const wcUser = wallClock(cfg.timezone, now)
216
- const wcBilling = wallClock(BILLING_TIMEZONE, now)
217
- const weekend = isWeekend(wcUser.weekday)
218
- const peak = cfg.enabled && !weekend && isInPeak(wcBilling, cfg.peakWindows || [])
219
- return json(200, {
220
- ok: true,
221
- status: {
222
- phase: weekend ? 'weekend' : peak ? 'peak' : 'off-peak',
223
- weekend,
224
- peak,
225
- state: lastState,
226
- enabled: cfg.enabled,
227
- weekendMode: cfg.weekendMode,
228
- timezone: cfg.timezone,
229
- billingTimezone: BILLING_TIMEZONE,
230
- now: now.toISOString(),
231
- },
232
- })
233
- }
234
- // GET /session-guard/diag —— 运行时诊断:settings 服务形状 + 已注册 namespace 列表
235
- if (method === 'GET' && url.pathname === '/session-guard/diag') {
236
- const settings = ctx.settings
237
- let hasSettings = !!settings && typeof settings === 'object'
238
- let hasRegister = typeof (settings && settings.register) === 'function'
239
- let namespaces = null
240
- let describeErr = null
241
- try {
242
- const d = typeof settings.describe === 'function' ? settings.describe() : null
243
- namespaces = Array.isArray(d) ? d.map((x) => x && x.ns) : d
244
- } catch (e) {
245
- describeErr = String(e && e.message || e)
246
- }
247
- return json(200, {
248
- ok: true,
249
- diag: {
250
- hasSettings,
251
- settingsType: hasSettings ? (settings.constructor ? settings.constructor.name : typeof settings) : typeof settings,
252
- settingsKeys: hasSettings ? Object.keys(settings) : [],
253
- hasRegister,
254
- hasGet: typeof (settings && settings.get) === 'function',
255
- hasDescribe: typeof (settings && settings.describe) === 'function',
256
- namespaces,
257
- describeErr,
258
- ns: NS,
259
- schemaOk: !!SettingsSchema && typeof SettingsSchema === 'function',
260
- },
261
- })
262
- }
263
- // POST /session-guard/rpc { action, sessionId, ... }
264
- if (method === 'POST' && url.pathname === '/session-guard/rpc') {
265
- const chunks = []
266
- for await (const c of req) chunks.push(c)
267
- const body = Buffer.concat(chunks).toString('utf8')
268
- let parsed = {}
269
- try {
270
- parsed = body ? JSON.parse(body) : {}
271
- } catch {
272
- return json(400, { ok: false, error: 'invalid json' })
273
- }
274
- const sessionId = String(parsed.sessionId ?? '')
275
- if (!sessionId) return json(400, { ok: false, error: 'missing sessionId' })
276
- const action = String(parsed.action ?? '')
277
- if (action === 'stopNextTurn') return json(200, { ok: true, result: await bridge.stopNextTurn(sessionId, parsed) })
278
- if (action === 'resume') return json(200, { ok: true, result: await bridge.resume(sessionId, parsed) })
279
- if (action === 'lockQueue') return json(200, { ok: true, result: bridge.lockQueue(sessionId, parsed.reason) })
280
- if (action === 'unlockQueue') return json(200, { ok: true, result: bridge.unlockQueue(sessionId) })
281
- if (action === 'state') return json(200, { ok: true, state: bridge.state(sessionId) })
282
- return json(400, { ok: false, error: `unknown action ${action}` })
283
- }
284
- return json(404, { ok: false, error: `unknown ${method} ${url.pathname}` })
285
- } catch (e) {
286
- ctx.logger?.error?.(`[session-guard] route error: ${String(e && e.message || e)}`)
287
- res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
288
- res.end(JSON.stringify({ ok: false, error: String(e && e.message || e) }))
289
- }
290
- },
291
- }), 'session-guard: routes')
292
- }
293
- }
1
+ /**
2
+ * dsh-session-guard — host half。
3
+ *
4
+ * 高峰自动会话门:
5
+ * - 每 30s tick 判定状态(NORMAL ↔ PAUSED_PEAK),纯状态机见 scheduler.js。
6
+ * - 入峰(且非周末):对所有 running root session 调 gate.stopNextTurn
7
+ * (有 taskControl → 会话门 safe+wait;无 → 回退锁等待队列)。
8
+ * - 退峰/周末:gate.resume 全部。
9
+ * - `ctx.provide('sessionGuard')` 冗余端口,input-traffic 冻结按钮透传接入。
10
+ * - 设置:设置 → 插件 → session-guard 子板块,简单开关(enabled / weekendMode / queueFallback)。
11
+ * schemastery 为常规 dependency,设置经 `ctx.inject(['settings'])` 本地接口注册(src/settings.js,
12
+ * 对齐 dsh-thinking-levels;不 value-import dsh-settings);设置服务缺失时 fail-open 用默认配置照常运行。
13
+ */
14
+ import { computeState, transition } from './scheduler.js'
15
+ import { wallClock, isWeekend, isInPeak, BILLING_TIMEZONE } from './time.js'
16
+ import { createStore } from './store.js'
17
+ import { createGate } from './gate.js'
18
+ import { createBridge } from './bridge.js'
19
+ import { createRetry } from './retry.js'
20
+ import { createPauseStore } from './pause-store.js'
21
+ import { createPauseGate } from './pause-gate.js'
22
+ import { createStepGate } from './step-gate.js'
23
+ import { detectTaskControl } from './detect.js'
24
+ import { makeIsRoot } from './request-guard.js'
25
+ import { createWiring } from './wiring.js'
26
+ import { NS, DEFAULT_SETTINGS, SettingsSchema, registerSettings } from './settings.js'
27
+
28
+ export const name = 'session-guard'
29
+ export const inject = ['agents', 'webServer', 'settings', 'timer', 'commands', 'goals']
30
+
31
+ export { NS, DEFAULT_SETTINGS }
32
+
33
+ export function apply(ctx) {
34
+ const store = createStore()
35
+ let lastState = null
36
+
37
+ /** 读实时设置(settings 服务不可用时回退默认)。 */
38
+ function readCfg() {
39
+ try {
40
+ const v = ctx.settings.get(NS)
41
+ return v && typeof v === 'object' ? { ...DEFAULT_SETTINGS, ...v } : { ...DEFAULT_SETTINGS }
42
+ } catch {
43
+ return { ...DEFAULT_SETTINGS }
44
+ }
45
+ }
46
+
47
+ // ── SSE 推送(v0.2.0):step 门状态变化时立刻推给对应会话页面 ──
48
+ // 客户端「暂停会话 / 继续会话」按钮据此更新,无需等轮询。
49
+ const sseClients = new Set()
50
+
51
+ function sseSend(client, payload) {
52
+ try {
53
+ client.res.write(`data: ${JSON.stringify(payload)}\n\n`)
54
+ } catch {
55
+ /* 连接已断开:交给 close 事件清理 */
56
+ }
57
+ }
58
+
59
+ function broadcastStep(sessionId, stepState) {
60
+ const id = String(sessionId)
61
+ for (const client of [...sseClients]) {
62
+ if (client.sessionId !== '' && client.sessionId !== id) continue
63
+ sseSend(client, { type: 'step', sessionId: id, state: stepState })
64
+ }
65
+ }
66
+
67
+ // ── 自研会话门(脱离 dsh-task-control,真暂停)──
68
+ // 状态持久化 + 引擎;经 gate.stopNextTurn/resume 主路径接入;/pause /resume /cancel 命令。
69
+ // v0.2.0 追加 step 级门控(src/step-gate.js):stepGate 与 pauseGate/wiring 互相需要,
70
+ // 用 late-bound 引用装配,避免构造期循环依赖。
71
+ const pauseStore = createPauseStore()
72
+ let pauseGateRef = null
73
+ let wiringRef = null
74
+ const stepGate = createStepGate({
75
+ getSettings: readCfg,
76
+ shouldHoldSession: (cfg, id) => (wiringRef ? wiringRef.shouldPauseSession(cfg, id) : true),
77
+ isHeldByDeferrals: (id) => (wiringRef ? wiringRef.deferrals.has(id) : false),
78
+ isRootAgent: makeIsRoot(ctx),
79
+ pauseGate: { pause: (id, opts) => pauseGateRef.pause(id, opts) },
80
+ logger: ctx.logger,
81
+ onChange: (id, snapshot) => broadcastStep(id, snapshot),
82
+ })
83
+ const pauseGate = createPauseGate({ ctx, pauseStore, stepGate })
84
+ pauseGateRef = pauseGate
85
+ const gate = createGate({ getCtx: () => ctx, getSettings: readCfg, store, pauseGate })
86
+ const bridge = createBridge(ctx, gate, store, pauseGate, stepGate)
87
+
88
+ // ── 官方 provider 二维判定接线(目标追踪 / 请求级守卫 / 入峰过滤 / 退峰释放)──
89
+ // 纯逻辑见 provider.js / provider-directory.js / deferrals.js / request-guard.js / targets.js,
90
+ // 接线与编排见 wiring.js;这里只负责装配与生命周期。
91
+ const wiring = createWiring({ ctx, getSettings: readCfg, gate, stepGate, logger: ctx.logger })
92
+ wiringRef = wiring
93
+
94
+ // ── 冗余端口:input-traffic 冻结按钮透传接入(D5/D6/D8)──
95
+ ctx.provide('sessionGuard', bridge)
96
+
97
+ // ── 自研会话门:安全边界监听(session/event 落地延迟暂停)──
98
+ // 监听在命令注册之前,让任何会话事件都能在安全边界落地 pending pause。
99
+ // 同一条监听顺带做「最近真实目标」追踪(request/header 两版本通吃)。
100
+ if (typeof ctx.on === 'function') {
101
+ ctx.effect(() => ctx.on('session/event', (session, event) => {
102
+ try {
103
+ pauseGate.handleEvent(session, event)
104
+ } catch (e) {
105
+ ctx.logger?.warn?.('[session-guard] session event handling failed: ' + String(e))
106
+ }
107
+ wiring.onSessionEvent(session, event)
108
+ }), 'session-guard: pause-gate events')
109
+ }
110
+
111
+ // ── 请求级守卫(agent/request waterfall)──
112
+ // 覆盖缺口:tick 只在状态跳变时处理 running 会话,入峰后新启动 / 中途切官方都会漏。
113
+ ctx.effect(() => wiring.installGuard(), 'session-guard: request guard')
114
+
115
+ // ── step 级门控(agent/pre-step waterfall,v0.2.0)──
116
+ // 高峰 + 非周末 + 目标官方 → 在下一个 step 的模型请求前拉门;退峰原地续跑。
117
+ ctx.effect(() => wiring.installStepGuard(), 'session-guard: step guard')
118
+
119
+ ctx.effect(() => () => wiring.dispose(), 'session-guard: wiring dispose')
120
+
121
+ // ── 手动会话门命令(/pause /resume /cancel,全量移植)──
122
+ if (typeof ctx.commands?.register === 'function') {
123
+ const tokensOf = (rawInput) => String(rawInput ?? '').trim().split(/\s+/).filter(Boolean)
124
+ ctx.effect(() => ctx.commands.register({
125
+ name: 'pause',
126
+ description: 'pause the running task (safe: defers to the safe boundary; force: interrupts tools and reasoning now; wait: let reasoning finish; bare /pause follows the pause settings)',
127
+ input: { hint: '[force|safe] [stop|wait]' },
128
+ handler: (invocation) => {
129
+ const sid = String(invocation?.agent?.id ?? '')
130
+ if (!sid) return { kind: 'error', text: 'no session for this command' }
131
+ const opts = {}
132
+ for (const t of tokensOf(invocation.rawInput)) {
133
+ if (t === 'force' || t === 'safe') opts.mode = t
134
+ if (t === 'stop' || t === 'wait') opts.reason = t
135
+ }
136
+ return pauseGate.pause(sid, opts)
137
+ },
138
+ }))
139
+ ctx.effect(() => ctx.commands.register({
140
+ name: 'resume',
141
+ description: 'resume the paused task and continue from the pause point (a force-paused task with an interrupted tool needs `confirm`, plus `rerun`/`skip` for the tool)',
142
+ input: { hint: '[confirm] [rerun|skip]' },
143
+ handler: (invocation) => {
144
+ const sid = String(invocation?.agent?.id ?? '')
145
+ if (!sid) return { kind: 'error', text: 'no session for this command' }
146
+ const tokens = tokensOf(invocation.rawInput)
147
+ // v0.2.0:step 门挂起时,「恢复」= 放行该 step 且本峰内不再拦(用户显式继续)。
148
+ bridge.stepResume(sid, { bypass: true, reason: 'command' })
149
+ return pauseGate.resume(sid, {
150
+ confirm: tokens.includes('confirm'),
151
+ choice: tokens.includes('skip') ? 'skip' : 'rerun',
152
+ })
153
+ },
154
+ }))
155
+ ctx.effect(() => ctx.commands.register({
156
+ name: 'cancel',
157
+ description: 'cancel the running task (stops the current turn immediately, keeps the queue)',
158
+ handler: (invocation) => {
159
+ const sid = String(invocation?.agent?.id ?? '')
160
+ if (!sid) return { kind: 'error', text: 'no session for this command' }
161
+ bridge.stepResume(sid, { bypass: false, reason: 'command' })
162
+ return pauseGate.cancel(sid)
163
+ },
164
+ }))
165
+ }
166
+
167
+ // ── 后端自动重试(D9):冻结/门控期间让路,绝不绕过会话门 ──
168
+ createRetry({
169
+ ctx,
170
+ getSettings: readCfg,
171
+ isFrozen: (sessionId) => {
172
+ const st = bridge.state(sessionId)
173
+ if (st.queueLocked) return true
174
+ if (st.paused) return true // 自研会话门真暂停
175
+ if (st.pausedStep) return true // v0.2.0:step 门挂起同样让路,绝不绕过会话门
176
+ if (st.taskControl && st.taskControl.paused) return true
177
+ return false
178
+ },
179
+ })
180
+
181
+ // ── 设置子板块(设置 → 插件 → session-guard,简单开关)──
182
+ // fail-open:原生设置栈可用才注册,缺失则静默降级用默认配置(永不因设置依赖而崩)。
183
+ void registerSettings(ctx)
184
+
185
+ // ── 状态机驱动(30s tick)──
186
+ // 入峰:只暂停「最近目标为官方或 unknown」的 running 会话(providerGuard 关闭时退回全部);
187
+ // 退峰:先放行挂起的请求,再按 deferredResume / offPeakAutoResume 决定续跑(见 wiring.js)。
188
+ async function onEnterPeak(cfg) {
189
+ await wiring.onEnterPeak(cfg)
190
+ }
191
+
192
+ async function onLeavePeak(cfg) {
193
+ await wiring.onLeavePeak(cfg)
194
+ }
195
+
196
+ function tick() {
197
+ try {
198
+ const cfg = readCfg()
199
+ const next = computeState(cfg, new Date())
200
+ if (lastState === null) {
201
+ lastState = next
202
+ return // 首次 tick 只记录基线,不触发(避免装插件瞬间误暂停)
203
+ }
204
+ const t = transition(lastState, next)
205
+ if (t.enter) {
206
+ lastState = { ...next }
207
+ void onEnterPeak(cfg)
208
+ } else if (t.leave) {
209
+ lastState = { ...next }
210
+ void onLeavePeak(cfg)
211
+ } else {
212
+ lastState = next
213
+ }
214
+ } catch (e) {
215
+ ctx.logger?.error?.(`[session-guard] tick failed: ${String(e && e.message || e)}`)
216
+ }
217
+ }
218
+
219
+ ctx.effect(() => ctx.timer.interval(tick, 30_000), 'session-guard: tick')
220
+ tick()
221
+
222
+ // ── HTTP 路由 ──
223
+ if (ctx.webServer && typeof ctx.webServer.register === 'function') {
224
+ ctx.effect(() => ctx.webServer.register({
225
+ kind: 'prefix',
226
+ path: '/session-guard',
227
+ handler: async (req, res) => {
228
+ try {
229
+ const url = new URL(req.url ?? '/', 'http://dsh.local')
230
+ const method = req.method ?? 'GET'
231
+ const json = (code, payload) => {
232
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
233
+ res.end(JSON.stringify(payload))
234
+ }
235
+ // GET /session-guard/state?session=<id>
236
+ if (method === 'GET' && url.pathname === '/session-guard/state') {
237
+ const sessionId = url.searchParams.get('session') ?? ''
238
+ if (!sessionId) return json(400, { ok: false, error: 'missing session' })
239
+ const st = bridge.state(sessionId)
240
+ return json(200, {
241
+ ok: true,
242
+ state: st,
243
+ // v0.2.0:PRD §6.2 的 `paused: { step, turn }` 形状(顶层,兼容既有 state.paused 布尔)
244
+ // step = 已拉门;manual = 已请求但还没到边界(按钮同样显示「继续会话」)
245
+ paused: { step: st.pausedStep === true, turn: st.paused === true, manual: st.stepManual === true },
246
+ stepGate: {
247
+ held: st.pausedStep === true,
248
+ manual: st.stepManual === true,
249
+ since: st.stepHeldSince,
250
+ bypass: st.stepBypass === true,
251
+ },
252
+ target: wiring.targets.get(sessionId),
253
+ held: wiring.deferrals.has(sessionId),
254
+ deferred: wiring.deferrals.isDeferred(sessionId),
255
+ })
256
+ }
257
+ // GET /session-guard/events?session=<id> —— SSE:step 门状态变化即时推送
258
+ if (method === 'GET' && url.pathname === '/session-guard/events') {
259
+ const sessionId = url.searchParams.get('session') ?? ''
260
+ res.writeHead(200, {
261
+ 'content-type': 'text/event-stream; charset=utf-8',
262
+ 'cache-control': 'no-cache, no-transform',
263
+ connection: 'keep-alive',
264
+ })
265
+ res.write(': connected\n\n')
266
+ const client = { sessionId, res }
267
+ sseClients.add(client)
268
+ sseSend(client, { type: 'step', sessionId, state: stepGate.state(sessionId) })
269
+ const keepAlive = setInterval(() => {
270
+ try {
271
+ res.write(': ping\n\n')
272
+ } catch {
273
+ /* 连接已断开 */
274
+ }
275
+ }, 25_000)
276
+ if (typeof keepAlive.unref === 'function') keepAlive.unref()
277
+ const cleanup = () => {
278
+ clearInterval(keepAlive)
279
+ sseClients.delete(client)
280
+ }
281
+ req.on?.('close', cleanup)
282
+ res.on?.('close', cleanup)
283
+ return undefined
284
+ }
285
+ // GET /session-guard/provider?provider=<id> —— 官方判定诊断(排查误判用)
286
+ if (method === 'GET' && url.pathname === '/session-guard/provider') {
287
+ const provider = url.searchParams.get('provider') ?? ''
288
+ if (!provider) return json(400, { ok: false, error: 'missing provider' })
289
+ return json(200, { ok: true, verdict: wiring.directory.describe(provider) })
290
+ }
291
+ // GET /session-guard/settings
292
+ if (method === 'GET' && url.pathname === '/session-guard/settings') {
293
+ const cfg = readCfg()
294
+ return json(200, { ok: true, settings: cfg, taskControlAvailable: detectTaskControl(ctx) })
295
+ }
296
+ // GET /session-guard/status —— 全局当前阶段(状态徽标轮询用)
297
+ // 峰谷判定:固定北京时间(BILLING_TIMEZONE),与 DeepSeek 官方计费一致
298
+ // 周末判定:用配置时区(用户本地的周末)
299
+ if (method === 'GET' && url.pathname === '/session-guard/status') {
300
+ const cfg = readCfg()
301
+ const now = new Date()
302
+ const wcUser = wallClock(cfg.timezone, now)
303
+ const wcBilling = wallClock(BILLING_TIMEZONE, now)
304
+ const weekend = isWeekend(wcUser.weekday)
305
+ const peak = cfg.enabled && !weekend && isInPeak(wcBilling, cfg.peakWindows || [])
306
+ return json(200, {
307
+ ok: true,
308
+ status: {
309
+ phase: weekend ? 'weekend' : peak ? 'peak' : 'off-peak',
310
+ weekend,
311
+ peak,
312
+ state: lastState,
313
+ enabled: cfg.enabled,
314
+ weekendMode: cfg.weekendMode,
315
+ providerGuard: cfg.providerGuard === true,
316
+ stepLevelPause: cfg.stepLevelPause === true,
317
+ held: wiring.deferrals.size(),
318
+ deferred: wiring.deferrals.deferredSize(),
319
+ stepHeld: stepGate.heldIds().length,
320
+ timezone: cfg.timezone,
321
+ billingTimezone: BILLING_TIMEZONE,
322
+ now: now.toISOString(),
323
+ },
324
+ })
325
+ }
326
+ // GET /session-guard/diag —— 运行时诊断:settings 服务形状 + 已注册 namespace 列表
327
+ if (method === 'GET' && url.pathname === '/session-guard/diag') {
328
+ const settings = ctx.settings
329
+ let hasSettings = !!settings && typeof settings === 'object'
330
+ let hasRegister = typeof (settings && settings.register) === 'function'
331
+ let namespaces = null
332
+ let describeErr = null
333
+ try {
334
+ const d = typeof settings.describe === 'function' ? settings.describe() : null
335
+ namespaces = Array.isArray(d) ? d.map((x) => x && x.ns) : d
336
+ } catch (e) {
337
+ describeErr = String(e && e.message || e)
338
+ }
339
+ return json(200, {
340
+ ok: true,
341
+ diag: {
342
+ hasSettings,
343
+ settingsType: hasSettings ? (settings.constructor ? settings.constructor.name : typeof settings) : typeof settings,
344
+ settingsKeys: hasSettings ? Object.keys(settings) : [],
345
+ hasRegister,
346
+ hasGet: typeof (settings && settings.get) === 'function',
347
+ hasDescribe: typeof (settings && settings.describe) === 'function',
348
+ namespaces,
349
+ describeErr,
350
+ ns: NS,
351
+ schemaOk: !!SettingsSchema && typeof SettingsSchema === 'function',
352
+ providerGuard: readCfg().providerGuard === true,
353
+ configurableProviders: wiring.directory.entries().length,
354
+ held: wiring.deferrals.size(),
355
+ deferred: wiring.deferrals.deferredSize(),
356
+ stepGate: {
357
+ held: stepGate.heldIds(),
358
+ bypass: stepGate._bypassed(),
359
+ timeoutMs: readCfg().stepGateTimeoutMs,
360
+ enabled: readCfg().stepLevelPause === true,
361
+ },
362
+ },
363
+ })
364
+ }
365
+ // POST /session-guard/rpc { action, sessionId, ... }
366
+ if (method === 'POST' && url.pathname === '/session-guard/rpc') {
367
+ const chunks = []
368
+ for await (const c of req) chunks.push(c)
369
+ const body = Buffer.concat(chunks).toString('utf8')
370
+ let parsed = {}
371
+ try {
372
+ parsed = body ? JSON.parse(body) : {}
373
+ } catch {
374
+ return json(400, { ok: false, error: 'invalid json' })
375
+ }
376
+ const sessionId = String(parsed.sessionId ?? '')
377
+ if (!sessionId) return json(400, { ok: false, error: 'missing sessionId' })
378
+ const action = String(parsed.action ?? '')
379
+ if (action === 'stopNextTurn') return json(200, { ok: true, result: await bridge.stopNextTurn(sessionId, parsed) })
380
+ if (action === 'resume') return json(200, { ok: true, result: await bridge.resume(sessionId, parsed) })
381
+ if (action === 'lockQueue') return json(200, { ok: true, result: bridge.lockQueue(sessionId, parsed.reason) })
382
+ if (action === 'unlockQueue') return json(200, { ok: true, result: bridge.unlockQueue(sessionId) })
383
+ if (action === 'stepResume') {
384
+ return json(200, {
385
+ ok: true,
386
+ result: bridge.stepResume(sessionId, {
387
+ bypass: parsed.bypass !== false,
388
+ reason: typeof parsed.reason === 'string' && parsed.reason !== '' ? parsed.reason : 'rpc',
389
+ }),
390
+ })
391
+ }
392
+ if (action === 'stepPause') return json(200, { ok: true, result: bridge.stepPause(sessionId) })
393
+ if (action === 'state') return json(200, { ok: true, state: bridge.state(sessionId) })
394
+ return json(400, { ok: false, error: `unknown action ${action}` })
395
+ }
396
+ return json(404, { ok: false, error: `unknown ${method} ${url.pathname}` })
397
+ } catch (e) {
398
+ ctx.logger?.error?.(`[session-guard] route error: ${String(e && e.message || e)}`)
399
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
400
+ res.end(JSON.stringify({ ok: false, error: String(e && e.message || e) }))
401
+ }
402
+ },
403
+ }), 'session-guard: routes')
404
+ }
405
+ }