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/CHANGELOG.ja.md +75 -29
- package/CHANGELOG.ko.md +75 -29
- package/CHANGELOG.md +146 -29
- package/INSTALL.ja.md +83 -42
- package/INSTALL.ko.md +83 -42
- package/INSTALL.md +86 -44
- package/INSTALL.zh.md +82 -42
- package/README.en.md +303 -170
- package/README.ja.md +177 -106
- package/README.ko.md +177 -106
- package/README.md +159 -20
- package/dsh.plugin.json +13 -0
- package/lib/client.js +397 -11
- package/lib/client.js.map +1 -1
- package/package.json +101 -78
- package/src/bridge.js +61 -1
- package/src/client/badge-text.ts +49 -0
- package/src/client/index.ts +11 -1
- package/src/client/locales.ts +163 -95
- package/src/client/pause-button-text.ts +60 -0
- package/src/client/pause-button.tsx +129 -0
- package/src/client/settings-card.tsx +391 -191
- package/src/client/status-badge.tsx +59 -66
- package/src/client/styles.ts +30 -0
- package/src/deferrals.js +197 -0
- package/src/index.js +405 -293
- package/src/pause-gate.js +472 -416
- package/src/provider-directory.js +101 -0
- package/src/provider.js +139 -0
- package/src/request-guard.js +191 -0
- package/src/retry.js +227 -202
- package/src/settings.js +118 -97
- package/src/step-gate.js +399 -0
- package/src/targets.js +70 -0
- package/src/time.js +195 -104
- package/src/tool-call-id.js +44 -0
- package/src/wiring.js +305 -0
package/src/wiring.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 接线层(host):把纯逻辑模块装到 cordis 事件/服务上。
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 目标追踪:`session/event` 的 `request/header`(两版本)+ `model/selection`(0.1.2+)
|
|
6
|
+
* - 请求级守卫:`agent/request` waterfall(覆盖入峰后新会话 / 中途切官方的缺口)
|
|
7
|
+
* - 入峰:只暂停「最近目标为官方或 unknown」的 running 会话;**已 hold 的跳过**(互斥铁律)
|
|
8
|
+
* - 退峰:先 `releaseAll` 放行挂起的请求,再按 `deferredResume` / `offPeakAutoResume` 决定续跑
|
|
9
|
+
* - 精确释放:`msUntilOffPeak` 定时器在退峰瞬间放行(30s tick 只是兜底)
|
|
10
|
+
*
|
|
11
|
+
* 本模块不做任何 `@deepseek-ai/*` 值导入;所有上游服务缺失都 fail-open 降级。
|
|
12
|
+
*/
|
|
13
|
+
import { createProviderDirectory } from './provider-directory.js'
|
|
14
|
+
import { createDeferrals } from './deferrals.js'
|
|
15
|
+
import { createRequestGuard } from './request-guard.js'
|
|
16
|
+
import { createTargets, UNKNOWN } from './targets.js'
|
|
17
|
+
import { msUntilOffPeak, shouldPause } from './time.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} deps
|
|
21
|
+
* @param {object} deps.ctx host context
|
|
22
|
+
* @param {()=>object} deps.getSettings 读实时配置
|
|
23
|
+
* @param {{stopNextTurn:Function, resume:Function}} deps.gate 会话门
|
|
24
|
+
* @param {ReturnType<import('./step-gate.js').createStepGate>} [deps.stepGate] step 级门控(v0.2.0)
|
|
25
|
+
* @param {object} [deps.logger]
|
|
26
|
+
* @param {()=>Date} [deps.clock]
|
|
27
|
+
* @param {typeof setTimeout} [deps.setTimer]
|
|
28
|
+
* @param {typeof clearTimeout} [deps.clearTimer]
|
|
29
|
+
* @param {boolean} [deps.unrefTimers]
|
|
30
|
+
*/
|
|
31
|
+
export function createWiring({
|
|
32
|
+
ctx,
|
|
33
|
+
getSettings,
|
|
34
|
+
gate,
|
|
35
|
+
stepGate,
|
|
36
|
+
logger,
|
|
37
|
+
clock = () => new Date(),
|
|
38
|
+
setTimer = setTimeout,
|
|
39
|
+
clearTimer = clearTimeout,
|
|
40
|
+
unrefTimers = true,
|
|
41
|
+
}) {
|
|
42
|
+
const warn = (m) => logger?.warn?.(`[session-guard] ${m}`)
|
|
43
|
+
const targets = createTargets()
|
|
44
|
+
const directory = createProviderDirectory({ ctx, getSettings, warn })
|
|
45
|
+
const deferrals = createDeferrals({
|
|
46
|
+
maxHoldMs: () => {
|
|
47
|
+
const cfg = safeCfg()
|
|
48
|
+
return typeof cfg.deferredMaxHoldMs === 'number' ? cfg.deferredMaxHoldMs : 6 * 60 * 60 * 1000
|
|
49
|
+
},
|
|
50
|
+
unrefTimers,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
function safeCfg() {
|
|
54
|
+
try {
|
|
55
|
+
const v = getSettings()
|
|
56
|
+
return v && typeof v === 'object' ? v : {}
|
|
57
|
+
} catch {
|
|
58
|
+
return {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let releaseTimer = null
|
|
63
|
+
/** 本插件因入峰而暂停的会话(只自动恢复这些,绝不碰用户手动暂停的会话)。 */
|
|
64
|
+
const pausedByPeak = new Set()
|
|
65
|
+
|
|
66
|
+
/** 安排一次「退峰瞬间」的释放检查(幂等:重复调用只保留最新一个定时器)。 */
|
|
67
|
+
function scheduleRelease() {
|
|
68
|
+
const cfg = safeCfg()
|
|
69
|
+
const ms = msUntilOffPeak(cfg, clock())
|
|
70
|
+
if (!(ms > 0)) return 0
|
|
71
|
+
if (releaseTimer !== null) clearTimer(releaseTimer)
|
|
72
|
+
releaseTimer = setTimer(() => {
|
|
73
|
+
releaseTimer = null
|
|
74
|
+
try {
|
|
75
|
+
const nowCfg = safeCfg()
|
|
76
|
+
if (shouldPause(nowCfg, clock()).pause) {
|
|
77
|
+
// 设置热改导致仍是高峰 → 重新排
|
|
78
|
+
scheduleRelease()
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
void onLeavePeak(nowCfg)
|
|
82
|
+
} catch (e) {
|
|
83
|
+
warn(`release timer failed: ${String(e && e.message || e)}`)
|
|
84
|
+
}
|
|
85
|
+
}, ms)
|
|
86
|
+
if (unrefTimers && releaseTimer && typeof releaseTimer.unref === 'function') releaseTimer.unref()
|
|
87
|
+
return ms
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const guard = createRequestGuard({
|
|
91
|
+
ctx,
|
|
92
|
+
getSettings,
|
|
93
|
+
directory,
|
|
94
|
+
deferrals,
|
|
95
|
+
gate,
|
|
96
|
+
logger,
|
|
97
|
+
clock,
|
|
98
|
+
scheduleRelease,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/** `session/event` → 目标追踪(形状异常降级 unknown,不抛出)+ 目标转非官方时自动恢复。 */
|
|
102
|
+
function onSessionEvent(session, event) {
|
|
103
|
+
try {
|
|
104
|
+
const id = session && session.id
|
|
105
|
+
if (typeof id !== 'string' || id === '') return
|
|
106
|
+
const rec = targets.update(id, event)
|
|
107
|
+
if (rec !== null) maybeAutoResume(id, rec)
|
|
108
|
+
} catch (e) {
|
|
109
|
+
warn(`target tracking failed: ${String(e && e.message || e)}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 高峰期目标从官方变为非官方 → 恢复本插件因入峰而暂停的会话(spec 需求 5)。
|
|
115
|
+
* v0.2.0:先释放 step 门控(幂等,无门则 no-op);turn 级只对 `pausedByPeak` 里的
|
|
116
|
+
* 会话生效(不碰用户手动暂停的);受 `deferredResume` 约束;
|
|
117
|
+
* 用 queueMicrotask 跳出当前事件派发,避免 resume→followup→事件 的重入。
|
|
118
|
+
*/
|
|
119
|
+
function maybeAutoResume(sessionId, rec) {
|
|
120
|
+
const cfg = safeCfg()
|
|
121
|
+
if (cfg.providerGuard !== true || cfg.deferredResume === false) return
|
|
122
|
+
if (!shouldPause(cfg, clock()).pause) return
|
|
123
|
+
const cls = directory.classify(rec.provider)
|
|
124
|
+
if (cls.official) return
|
|
125
|
+
// step 门控:目标转非官方 → 放行被挂起的 step(step gate 自身按目标判定,无需 pausedByPeak)
|
|
126
|
+
if (stepGate) {
|
|
127
|
+
const r = stepGate.release(sessionId, 'non-official')
|
|
128
|
+
if (r && r.released === true) {
|
|
129
|
+
logger?.info?.(`[session-guard] target switched to non-official "${rec.provider}" (matchedBy=${cls.matchedBy}) — releasing step gate for ${sessionId}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!pausedByPeak.has(sessionId)) return
|
|
133
|
+
pausedByPeak.delete(sessionId)
|
|
134
|
+
logger?.info?.(`[session-guard] target switched to non-official "${rec.provider}" (matchedBy=${cls.matchedBy}) — auto-resuming ${sessionId}`)
|
|
135
|
+
queueMicrotask(() => {
|
|
136
|
+
Promise.resolve(gate.resume(sessionId, { choice: 'rerun' })).catch((e) => {
|
|
137
|
+
warn(`auto-resume failed for ${sessionId}: ${String(e && e.message || e)}`)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 该会话是否应因高峰被暂停(官方 / unknown 才停;已知非官方放行)。 */
|
|
143
|
+
function shouldPauseSession(cfg, sessionId) {
|
|
144
|
+
if (cfg.providerGuard !== true) return true // 退回现有纯时间判定:全部暂停
|
|
145
|
+
const provider = targets.providerOf(sessionId)
|
|
146
|
+
if (provider === UNKNOWN) return true // 未知保守处理
|
|
147
|
+
const cls = directory.classify(provider)
|
|
148
|
+
return cls.official
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 入峰:暂停 running 会话(跳过已 hold / 已暂停 / 目标非官方)。 */
|
|
152
|
+
async function onEnterPeak(cfg) {
|
|
153
|
+
const agents = ctx && ctx.agents
|
|
154
|
+
if (!agents) return { paused: [], skipped: [] }
|
|
155
|
+
const roots = typeof agents.roots === 'function' ? agents.roots() : typeof agents.list === 'function' ? agents.list() : []
|
|
156
|
+
// Q1-A:step 级门控开启时,入峰不再 turn 级暂停——会话跑到下一个 pre-step 边界自动拉门。
|
|
157
|
+
const stepLevel = cfg.stepLevelPause === true && !!stepGate
|
|
158
|
+
const paused = []
|
|
159
|
+
const skipped = []
|
|
160
|
+
for (const agent of Array.isArray(roots) ? roots : []) {
|
|
161
|
+
const id = String((agent && agent.id) ?? '')
|
|
162
|
+
if (id === '' || !agent || agent.status !== 'running') continue
|
|
163
|
+
// 互斥铁律:请求已被 hold 的会话,请求级挂起本身就是暂停,绝不再调 pauseGate
|
|
164
|
+
if (deferrals.has(id)) {
|
|
165
|
+
skipped.push({ sessionId: id, why: 'held' })
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (!shouldPauseSession(cfg, id)) {
|
|
169
|
+
skipped.push({ sessionId: id, why: 'non-official', provider: targets.providerOf(id) })
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
if (stepLevel) {
|
|
173
|
+
// 新一轮高峰:清掉上一轮的 bypass,让 step 门重新生效。
|
|
174
|
+
stepGate.clearBypass(id)
|
|
175
|
+
paused.push({ sessionId: id, via: 'stepGate', ok: true })
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const r = await gate.stopNextTurn(id, { mode: cfg.pauseMode, reason: cfg.pauseReason })
|
|
180
|
+
if (!r || r.ok !== false) pausedByPeak.add(id)
|
|
181
|
+
paused.push({ sessionId: id, via: r && r.via, ok: r && r.ok })
|
|
182
|
+
} catch (e) {
|
|
183
|
+
warn(`stopNextTurn failed for ${id}: ${String(e && e.message || e)}`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
logger?.info?.(`[session-guard] peak entered — ${stepLevel ? 'step-level gate armed for' : 'paused'} ${paused.length} session(s): ${JSON.stringify(paused)}; skipped ${JSON.stringify(skipped)}`)
|
|
187
|
+
return { paused, skipped }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 给一条延后记录发续跑消息(与 retry.js 同构:plugin-source 消息,零运行时依赖)。 */
|
|
191
|
+
function sendResume(sessionId, text) {
|
|
192
|
+
const agents = ctx && ctx.agents
|
|
193
|
+
const agent = agents && typeof agents.get === 'function' ? agents.get(sessionId) : undefined
|
|
194
|
+
if (!agent || agent.status !== 'idle') return false
|
|
195
|
+
try {
|
|
196
|
+
agent.followup({
|
|
197
|
+
id: `session-guard-deferred-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
198
|
+
role: 'user',
|
|
199
|
+
content: [{ type: 'text', text }],
|
|
200
|
+
source: { kind: 'plugin', plugin: 'session-guard', form: 'notice' },
|
|
201
|
+
})
|
|
202
|
+
return true
|
|
203
|
+
} catch (e) {
|
|
204
|
+
warn(`deferred resume send failed for ${sessionId}: ${String(e && e.message || e)}`)
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 退峰:先释放 step 门(回合原地续跑),再放行挂起的请求、恢复暂停的会话。 */
|
|
210
|
+
async function onLeavePeak(cfg) {
|
|
211
|
+
if (releaseTimer !== null) {
|
|
212
|
+
clearTimer(releaseTimer)
|
|
213
|
+
releaseTimer = null
|
|
214
|
+
}
|
|
215
|
+
// 0) step 级门控:先放行被挂起的 step —— 回合本来就还开着,无需 followup。
|
|
216
|
+
if (stepGate) {
|
|
217
|
+
const releasedSteps = stepGate.releaseAll('off-peak')
|
|
218
|
+
stepGate.clearAllBypass()
|
|
219
|
+
if (releasedSteps.length > 0) logger?.info?.(`[session-guard] peak left — released ${releasedSteps.length} step gate(s): ${JSON.stringify(releasedSteps)}`)
|
|
220
|
+
}
|
|
221
|
+
// 1) 先取延后记录快照(releaseAll 会清空),再放行全部挂起。
|
|
222
|
+
const deferredSnapshot = deferrals.deferredList()
|
|
223
|
+
const { released } = deferrals.releaseAll('off-peak')
|
|
224
|
+
|
|
225
|
+
// 2) error 模式的续跑:deferredResume=false 时不自动继续(用户手动 /resume 或再发消息)
|
|
226
|
+
const resumed = []
|
|
227
|
+
if (cfg.deferredResume !== false) {
|
|
228
|
+
const text = typeof cfg.deferredResumeText === 'string' && cfg.deferredResumeText !== ''
|
|
229
|
+
? cfg.deferredResumeText
|
|
230
|
+
: '继续(高峰已过,自动继续)'
|
|
231
|
+
for (const rec of deferredSnapshot) {
|
|
232
|
+
if (sendResume(rec.sessionId, text)) resumed.push(rec.sessionId)
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
logger?.info?.('[session-guard] deferredResume disabled — deferred sessions stay parked')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 3) 低谷自动恢复:关掉则保持暂停,需手动恢复。
|
|
239
|
+
if (cfg.offPeakAutoResume === false) {
|
|
240
|
+
logger?.info?.(`[session-guard] off-peak auto-resume disabled — released ${released.length} held request(s), sessions stay paused`)
|
|
241
|
+
return { released, resumed }
|
|
242
|
+
}
|
|
243
|
+
const agents = ctx && ctx.agents
|
|
244
|
+
const roots = agents ? (typeof agents.roots === 'function' ? agents.roots() : typeof agents.list === 'function' ? agents.list() : []) : []
|
|
245
|
+
for (const agent of Array.isArray(roots) ? roots : []) {
|
|
246
|
+
const id = String((agent && agent.id) ?? '')
|
|
247
|
+
if (id === '') continue
|
|
248
|
+
try {
|
|
249
|
+
await gate.resume(id, { choice: 'rerun' })
|
|
250
|
+
} catch (e) {
|
|
251
|
+
warn(`resume failed for ${id}: ${String(e && e.message || e)}`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
pausedByPeak.clear()
|
|
255
|
+
logger?.info?.(`[session-guard] peak left — released ${released.length} held request(s), resumed ${resumed.length} deferred session(s)`)
|
|
256
|
+
return { released, resumed }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 安装请求级守卫(返回 disposer,交给 ctx.effect)。 */
|
|
260
|
+
function installGuard() {
|
|
261
|
+
return guard.install()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 安装 step 级门控(`agent/pre-step` waterfall,v0.2.0)。
|
|
266
|
+
* disposer 释放全部挂起门,避免卸载后 Promise 悬挂。
|
|
267
|
+
*/
|
|
268
|
+
function installStepGuard() {
|
|
269
|
+
if (!stepGate || !ctx || typeof ctx.on !== 'function') return () => {}
|
|
270
|
+
const off = ctx.on('agent/pre-step', (payload, next) => stepGate.hold(payload, next))
|
|
271
|
+
return () => {
|
|
272
|
+
if (typeof off === 'function') off()
|
|
273
|
+
stepGate.releaseAll('disposed')
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 插件卸载:清定时器 + 拒绝全部挂起(不泄漏 promise)。 */
|
|
278
|
+
function dispose() {
|
|
279
|
+
if (releaseTimer !== null) {
|
|
280
|
+
clearTimer(releaseTimer)
|
|
281
|
+
releaseTimer = null
|
|
282
|
+
}
|
|
283
|
+
pausedByPeak.clear()
|
|
284
|
+
if (stepGate) {
|
|
285
|
+
stepGate.releaseAll('disposed')
|
|
286
|
+
stepGate.clearAllBypass()
|
|
287
|
+
}
|
|
288
|
+
deferrals.rejectAll('disposed')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
targets,
|
|
293
|
+
directory,
|
|
294
|
+
deferrals,
|
|
295
|
+
guard,
|
|
296
|
+
onSessionEvent,
|
|
297
|
+
onEnterPeak,
|
|
298
|
+
onLeavePeak,
|
|
299
|
+
scheduleRelease,
|
|
300
|
+
shouldPauseSession,
|
|
301
|
+
installGuard,
|
|
302
|
+
installStepGuard,
|
|
303
|
+
dispose,
|
|
304
|
+
}
|
|
305
|
+
}
|