dsh-session-bridge 0.2.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.
package/src/monitor.ts ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * dsh-session-bridge monitor: 线程内的后台守护循环。
3
+ * 对一个"主任务会话"按配置间隔轮询其进度(复用 statusSnapshot/规则判定),
4
+ * 根据实际情况自动调度——卡住则 steer 催办/纠偏、卡住过久则 cancel 终止、
5
+ * 出现完成关键词且空闲则结束监控并留档。可选 LLM 增强:判定是否偏离主题。
6
+ * 作为插件自身的组件存在(非独立插件),用 session_bridge_monitor_start/_stop/_list 控制。
7
+ */
8
+ import { appendFileSync, mkdirSync } from 'node:fs'
9
+ import { join, dirname } from 'node:path'
10
+ import { homedir } from 'node:os'
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type LlmService from '@deepseek-ai/dsh-llm'
13
+ import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
14
+ import {
15
+ cancelLiveSession,
16
+ getLiveAgent,
17
+ sendLiveMessage,
18
+ statusSnapshot,
19
+ type BridgeStatusSnapshot,
20
+ } from './core.ts'
21
+
22
+ export interface MonitorConfig {
23
+ /** 目标主任务会话 id。 */
24
+ sessionId: string
25
+ /** 轮询间隔(毫秒),默认 10000。 */
26
+ intervalMs: number
27
+ /** 距最近一次事件超过该毫秒数即视为"卡住",默认 60000。 */
28
+ stalledMs?: number
29
+ /** 连续卡住超过多少次后自动 cancel 终止,默认 3。 */
30
+ maxStuckCycles?: number
31
+ /** 判定为完成后出现的文本关键词(任一命中即视为完成信号)。 */
32
+ doneKeywords: string[]
33
+ /** 卡住时注入的 steer 文本(催办/纠偏),默认催办。 */
34
+ onStallSteer?: string
35
+ /** 是否用 LLM 判断偏离主题(默认 false,用规则即可)。 */
36
+ useLlm?: boolean
37
+ /** LLM 判定偏离时的纠正 steer 文本。 */
38
+ onOffTrackSteer?: string
39
+ /** 监控日志文件路径(缺省 DSH_HOME/super-injector/dsh-session-bridge-monitor.log)。 */
40
+ logFile?: string
41
+ /** 监控会话的说明,仅用于展示。 */
42
+ label?: string
43
+ }
44
+
45
+ export interface MonitorEntryState {
46
+ config: MonitorConfig
47
+ startedAt: number
48
+ lastTickAt: number
49
+ stuckCount: number
50
+ lastAction: 'none' | 'steer' | 'cancel' | 'done' | 'lost' | 'offtrack' | 'steady'
51
+ lastActionAt: number | null
52
+ lastNote: string
53
+ done: boolean
54
+ cycles: number
55
+ }
56
+
57
+ const SCAN_MS = 5000
58
+
59
+ function dshLogFile(override: string | undefined): string {
60
+ if (override !== undefined && override !== '') return override
61
+ return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'super-injector', 'dsh-session-bridge-monitor.log')
62
+ }
63
+
64
+ export class SessionMonitor {
65
+ private readonly entries = new Map<string, MonitorEntryState>()
66
+ private timer: unknown | undefined
67
+ private readonly logFile: string
68
+
69
+ constructor(
70
+ private readonly ctx: Context,
71
+ logFile?: string,
72
+ ) {
73
+ this.logFile = dshLogFile(logFile)
74
+ }
75
+
76
+ /** 启动对一个会话的监控(幂等:已存在则更新配置)。 */
77
+ start(config: MonitorConfig): MonitorEntryState {
78
+ const existing = this.entries.get(config.sessionId)
79
+ const now = Date.now()
80
+ const entry: MonitorEntryState = existing !== undefined
81
+ ? { ...existing, config, lastTickAt: now }
82
+ : {
83
+ config,
84
+ startedAt: now,
85
+ lastTickAt: now,
86
+ stuckCount: 0,
87
+ lastAction: 'none',
88
+ lastActionAt: null,
89
+ lastNote: '监控已启动',
90
+ done: false,
91
+ cycles: 0,
92
+ }
93
+ this.entries.set(config.sessionId, entry)
94
+ this.ensureTimer()
95
+ this.log(entry, 'monitor start session=' + config.sessionId + ' interval=' + config.intervalMs
96
+ + ' stalled=' + String(config.stalledMs ?? 60000) + ' maxStuck=' + String(config.maxStuckCycles ?? 3))
97
+ return entry
98
+ }
99
+
100
+ /** 停止对一个会话的监控。返回是否存在。 */
101
+ stop(sessionId: string): boolean {
102
+ const existed = this.entries.delete(sessionId)
103
+ if (this.entries.size === 0) this.disposeTimer()
104
+ if (existed) this.stderr('[dsh-session-bridge] monitor stop session=' + sessionId)
105
+ return existed
106
+ }
107
+
108
+ /** 全部活动监控快照。 */
109
+ list(): MonitorEntryState[] {
110
+ return [...this.entries.values()].map((e) => ({ ...e, config: { ...e.config } }))
111
+ }
112
+
113
+ /** 移除所有监控(插件卸载用)。 */
114
+ dispose(): void {
115
+ this.disposeTimer()
116
+ this.entries.clear()
117
+ }
118
+
119
+ /** 立即触发一轮扫描(测试/手动)。 */
120
+ async tickNow(): Promise<void> {
121
+ await this.tick()
122
+ }
123
+
124
+ private ensureTimer(): void {
125
+ if (this.timer !== undefined) return
126
+ const timer = this.ctx as unknown as { setInterval(fn: () => void, ms: number): unknown }
127
+ this.timer = timer.setInterval(() => {
128
+ void this.tick().catch((error) => {
129
+ this.stderr('[dsh-session-bridge] monitor tick error: ' + String(error))
130
+ })
131
+ }, SCAN_MS)
132
+ }
133
+
134
+ private disposeTimer(): void {
135
+ if (this.timer === undefined) return
136
+ const ctx = this.ctx as unknown as { clearInterval(handle: unknown): void }
137
+ try { ctx.clearInterval(this.timer) } catch { /* noop */ }
138
+ this.timer = undefined
139
+ }
140
+
141
+ private async tick(): Promise<void> {
142
+ const now = Date.now()
143
+ for (const entry of this.entries.values()) {
144
+ if (entry.done) continue
145
+ if (now - entry.lastTickAt < entry.config.intervalMs) continue
146
+ entry.lastTickAt = now
147
+ entry.cycles += 1
148
+ await this.tickEntry(entry)
149
+ }
150
+ }
151
+
152
+ private async tickEntry(entry: MonitorEntryState): Promise<void> {
153
+ const { sessionId } = entry.config
154
+ const agent = getLiveAgent(this.ctx, sessionId)
155
+ if (agent === undefined) {
156
+ entry.stuckCount += 1
157
+ entry.lastAction = 'lost'
158
+ entry.lastActionAt = Date.now()
159
+ entry.lastNote = '目标会话离线(不在 live)'
160
+ this.log(entry, 'target not live; stuck=' + entry.stuckCount)
161
+ if (this.shouldCancel(entry)) this.doCancel(entry, '目标会话离线')
162
+ return
163
+ }
164
+ const snapshot = statusSnapshot(this.ctx, agent)
165
+
166
+ // 1) 完成判定:出现完成关键词(且空闲)。
167
+ if (this.isDone(entry, snapshot)) {
168
+ entry.done = true
169
+ entry.lastAction = 'done'
170
+ entry.lastActionAt = Date.now()
171
+ entry.lastNote = '检测到完成信号,监控结束'
172
+ this.log(entry, 'DONE → stop monitor')
173
+ this.stopWithLog(sessionId)
174
+ return
175
+ }
176
+
177
+ // 1b) 空闲且无待处理:任务已静默结束,收尾而不是反复催办/取消。
178
+ if (snapshot.running !== 'running' && !snapshot.pendingWork) {
179
+ entry.done = true
180
+ entry.lastAction = 'done'
181
+ entry.lastActionAt = Date.now()
182
+ entry.lastNote = '目标已空闲且无待处理,监控收尾'
183
+ this.log(entry, 'IDLE no-pending → stop monitor (settled)')
184
+ this.stopWithLog(sessionId)
185
+ return
186
+ }
187
+
188
+ // 2) 卡住判定:仅对 running 会话有意义(距最近事件超过阈值)。
189
+ const stalled = snapshot.running === 'running'
190
+ && snapshot.stalledMs !== null
191
+ && snapshot.stalledMs > (entry.config.stalledMs ?? 60000)
192
+ if (stalled) {
193
+ entry.stuckCount += 1
194
+ entry.lastActionAt = Date.now()
195
+ if (this.shouldCancel(entry)) {
196
+ this.doCancel(entry, '卡住超过 ' + entry.config.maxStuckCycles + ' 次')
197
+ return
198
+ }
199
+ // 3) 可选 LLM 偏离判定(有 llm 且开启时)。
200
+ if (entry.config.useLlm === true && this.hasLlm()) {
201
+ const offtrack = await this.judgeOffTrack(entry, snapshot, agent.options as { provider?: string; model?: string })
202
+ if (offtrack === 'offtrack') {
203
+ const steer = entry.config.onOffTrackSteer ?? '检测到任务偏离主题,请回到原始目标继续,并简要说明你下一步怎么做。'
204
+ sendLiveMessage(this.ctx, sessionId, steer, 'steer')
205
+ entry.lastAction = 'offtrack'
206
+ entry.lastNote = 'LLM 判定偏离主题,已 steer 纠偏'
207
+ entry.stuckCount = 0
208
+ this.log(entry, 'OFFTRACK → steer: ' + steer)
209
+ return
210
+ }
211
+ if (offtrack === 'stuck') {
212
+ const steer = entry.config.onStallSteer ?? '较长一段时间没有实质进展,请简明汇报当前进度并继续推进任务。'
213
+ sendLiveMessage(this.ctx, sessionId, steer, 'steer')
214
+ entry.lastAction = 'steer'
215
+ entry.lastNote = 'LLM 判定卡住,已 steer 催办'
216
+ this.log(entry, 'STUCK → steer: ' + steer)
217
+ return
218
+ }
219
+ // 'steady':LLM 认为仍在推进,重置。
220
+ entry.lastAction = 'steady'
221
+ entry.lastNote = 'LLM 判定仍在推进'
222
+ entry.stuckCount = 0
223
+ this.log(entry, 'LLM verdict=steady; reset stuck')
224
+ return
225
+ }
226
+ // 4) 纯规则:卡住 → steer 催办。
227
+ const steer = entry.config.onStallSteer ?? '较长一段时间没有实质进展,请简明汇报当前进度并继续推进任务。'
228
+ sendLiveMessage(this.ctx, sessionId, steer, 'steer')
229
+ entry.lastAction = 'steer'
230
+ entry.lastNote = '卡住 ' + entry.stuckCount + ' 次,已 steer 催办'
231
+ this.log(entry, 'STALL(#' + entry.stuckCount + ') → steer: ' + steer)
232
+ return
233
+ }
234
+
235
+ // 5) 正常推进:重置卡住计数。
236
+ if (entry.stuckCount !== 0) entry.stuckCount = 0
237
+ entry.lastAction = 'steady'
238
+ entry.lastActionAt = Date.now()
239
+ entry.lastNote = '正常推进(lastActivity now-' + String(snapshot.stalledMs ?? '?') + 'ms)'
240
+ this.log(entry, 'steady; lastActivity now-' + String(snapshot.stalledMs ?? '?') + 'ms')
241
+ }
242
+
243
+ private shouldCancel(entry: MonitorEntryState): boolean {
244
+ return entry.stuckCount >= (entry.config.maxStuckCycles ?? 3)
245
+ }
246
+
247
+ private doCancel(entry: MonitorEntryState, reason: string): void {
248
+ cancelLiveSession(this.ctx, entry.config.sessionId, false, reason)
249
+ entry.lastAction = 'cancel'
250
+ entry.lastActionAt = Date.now()
251
+ entry.lastNote = '连续卡住,已 cancel 终止: ' + reason
252
+ this.log(entry, 'CANCEL session=' + entry.config.sessionId + ' reason=' + reason)
253
+ }
254
+
255
+ private isDone(entry: MonitorEntryState, snapshot: BridgeStatusSnapshot): boolean {
256
+ const keywords = entry.config.doneKeywords ?? []
257
+ if (keywords.length === 0) return false
258
+ // 只有空闲才算真正收尾(避免 turn 中途误判完成)。
259
+ if (snapshot.running === 'running') return false
260
+ const text = `${snapshot.lastAssistantText ?? ''}\n${snapshot.recent.map((r) => r.text ?? '').join('\n')}`
261
+ if (!text) return false
262
+ return keywords.some((kw) => kw !== '' && text.includes(kw))
263
+ }
264
+
265
+ private hasLlm(): boolean {
266
+ const ctx = this.ctx as unknown as { llm?: LlmService }
267
+ return ctx.llm !== undefined
268
+ }
269
+
270
+ /** LLM 判定当前状态:'stuck' | 'offtrack' | 'steady'。失败回落 'steady'。 */
271
+ private async judgeOffTrack(entry: MonitorEntryState, snapshot: BridgeStatusSnapshot, route: { provider?: string; model?: string }): Promise<'stuck' | 'offtrack' | 'steady'> {
272
+ const ctx = this.ctx as unknown as { llm?: LlmService }
273
+ if (ctx.llm === undefined) return 'steady'
274
+ // 用主任务 agent 自身的模型路由(若无则无法发起 LLM 判定,回落规则)。
275
+ const provider = route.provider
276
+ const model = route.model
277
+ if (provider === undefined || model === undefined) return 'steady'
278
+ const recentText = snapshot.recent.map((r) => (r.role === 'user' ? '[user]' : '[assistant]') + ' ' + (r.text ?? r.reasoning ?? '')).join('\n').slice(-1200)
279
+ try {
280
+ let text = ''
281
+ const stream = ctx.llm.stream({
282
+ provider,
283
+ model,
284
+ system: '你是任务监控 agent。给定一个主任务会话的最新消息流,判定它当前处于哪种状态,只输出一个词:stuck(很久无实质进展)/ offtrack(明显偏离原始目标)/ steady(正常推进)。',
285
+ messages: [createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: recentText }] })],
286
+ temperature: 0,
287
+ reasoningEffort: ReasoningEffortId('off'),
288
+ maxTokens: 40,
289
+ })
290
+ for await (const chunk of stream) {
291
+ if (chunk.type === 'text-delta') text += chunk.text
292
+ }
293
+ const verdict = text.trim().toLowerCase()
294
+ if (verdict.startsWith('stuck')) return 'stuck'
295
+ if (verdict.startsWith('offtrack')) return 'offtrack'
296
+ return 'steady'
297
+ } catch {
298
+ return 'steady'
299
+ }
300
+ }
301
+
302
+ private stopWithLog(sessionId: string): void {
303
+ this.stderr('[dsh-session-bridge] monitor session=' + sessionId + ' marked done')
304
+ this.entries.delete(sessionId)
305
+ if (this.entries.size === 0) this.disposeTimer()
306
+ }
307
+
308
+ private log(entry: MonitorEntryState, msg: string): void {
309
+ try {
310
+ mkdirSync(dirname(this.logFile), { recursive: true })
311
+ appendFileSync(this.logFile, `[${new Date().toISOString()}] session=${entry.config.sessionId} lastReply=${entry.config.label ?? ''} ${msg}\n`)
312
+ } catch { /* 日志失败静默 */ }
313
+ }
314
+
315
+ private stderr(msg: string): void {
316
+ try {
317
+ console.warn(msg)
318
+ } catch { /* noop */ }
319
+ }
320
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * dsh-session-bridge 自维护会话登记表:记录 bridge 创建 / 恢复 / 发现过的会话
3
+ * (id、标题、cwd、workspace id、模型),落盘 ~/.dsh/session-bridge-registry.json,
4
+ * 使按名查找离线会话更可靠(离线会话标题不在 header 中)。
5
+ */
6
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
7
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
8
+ import { dirname } from 'node:path'
9
+
10
+ export interface BridgeRecord {
11
+ sessionId: string
12
+ title?: string
13
+ cwd?: string
14
+ workspaceId?: string
15
+ provider?: string
16
+ model?: string
17
+ createdAt: number
18
+ updatedAt: number
19
+ lastActivityAt?: number
20
+ source: 'create' | 'resume' | 'discover'
21
+ }
22
+
23
+ const RECORD_FILE_NAME = 'session-bridge-registry.json'
24
+
25
+ function recordFilePath(): string {
26
+ return dshHomePath(RECORD_FILE_NAME)
27
+ }
28
+
29
+ /** 解析登记表文件(缺失/损坏 → 空表)。 */
30
+ async function readRecords(): Promise<BridgeRecord[]> {
31
+ try {
32
+ const raw = await readFile(recordFilePath(), 'utf8')
33
+ const parsed = JSON.parse(raw) as unknown
34
+ if (!Array.isArray(parsed)) return []
35
+ return parsed.filter((entry): entry is BridgeRecord => {
36
+ const record = entry as Record<string, unknown> | null
37
+ return record !== null && typeof record === 'object' && typeof record.sessionId === 'string'
38
+ }).map((record) => ({
39
+ sessionId: record.sessionId,
40
+ ...(typeof record.title === 'string' ? { title: record.title } : {}),
41
+ ...(typeof record.cwd === 'string' ? { cwd: record.cwd } : {}),
42
+ ...(typeof record.workspaceId === 'string' ? { workspaceId: record.workspaceId } : {}),
43
+ ...(typeof record.provider === 'string' ? { provider: record.provider } : {}),
44
+ ...(typeof record.model === 'string' ? { model: record.model } : {}),
45
+ createdAt: typeof record.createdAt === 'number' ? record.createdAt : 0,
46
+ updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : 0,
47
+ ...(typeof record.lastActivityAt === 'number' ? { lastActivityAt: record.lastActivityAt } : {}),
48
+ source: record.source === 'create' || record.source === 'resume' || record.source === 'discover' ? record.source : 'discover',
49
+ }))
50
+ } catch {
51
+ return []
52
+ }
53
+ }
54
+
55
+ export class BridgeRegistry {
56
+ private records = new Map<string, BridgeRecord>()
57
+ private loaded = false
58
+
59
+ /** 加载登记表(幂等)。 */
60
+ async load(): Promise<void> {
61
+ if (this.loaded) return
62
+ for (const record of await readRecords()) this.records.set(record.sessionId, record)
63
+ this.loaded = true
64
+ }
65
+
66
+ /** 读取快照(自动加载,best-effort)。 */
67
+ async get(sessionId: string): Promise<BridgeRecord | undefined> {
68
+ await this.load().catch(() => undefined)
69
+ return this.records.get(sessionId)
70
+ }
71
+
72
+ /** 全部记录(自动加载;返回顺序=登记顺序)。 */
73
+ async all(): Promise<BridgeRecord[]> {
74
+ await this.load().catch(() => undefined)
75
+ return [...this.records.values()]
76
+ }
77
+
78
+ /** 新增 / 更新一条记录并落盘(best-effort,不阻塞调用方)。 */
79
+ record(input: { sessionId: string; title?: string; cwd?: string; workspaceId?: string; provider?: string; model?: string; source: 'create' | 'resume' | 'discover' }): void {
80
+ const now = Date.now()
81
+ const prior = this.records.get(input.sessionId)
82
+ const next: BridgeRecord = {
83
+ sessionId: input.sessionId,
84
+ ...(input.title !== undefined ? { title: input.title } : {}),
85
+ ...(input.cwd !== undefined ? { cwd: input.cwd } : {}),
86
+ ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
87
+ ...(input.provider !== undefined ? { provider: input.provider } : {}),
88
+ ...(input.model !== undefined ? { model: input.model } : {}),
89
+ createdAt: prior?.createdAt ?? now,
90
+ updatedAt: now,
91
+ ...(prior !== undefined ? { lastActivityAt: prior.lastActivityAt } : {}),
92
+ source: input.source,
93
+ }
94
+ this.records.set(input.sessionId, next)
95
+ void this.persist().catch((error: unknown) => {
96
+ console.warn('[dsh-session-bridge] registry write failed:', error instanceof Error ? error.message : String(error))
97
+ })
98
+ }
99
+
100
+ /** 上报一次活动(发消息 / 等待 / 读取)。永不覆盖登记的别名 title。 */
101
+ touch(sessionId: string, fields?: { cwd?: string; workspaceId?: string }): void {
102
+ const prior = this.records.get(sessionId)
103
+ if (prior === undefined) return
104
+ this.records.set(sessionId, {
105
+ ...prior,
106
+ ...(fields?.cwd !== undefined ? { cwd: fields.cwd } : {}),
107
+ ...(fields?.workspaceId !== undefined ? { workspaceId: fields.workspaceId } : {}),
108
+ updatedAt: Date.now(),
109
+ lastActivityAt: Date.now(),
110
+ })
111
+ void this.persist().catch((error: unknown) => {
112
+ console.warn('[dsh-session-bridge] registry write failed:', error instanceof Error ? error.message : String(error))
113
+ })
114
+ }
115
+
116
+ /** 落盘(best-effort)。 */
117
+ async persist(): Promise<void> {
118
+ const path = recordFilePath()
119
+ await mkdir(dirname(path), { recursive: true })
120
+ await writeFile(path, JSON.stringify([...this.records.values()], null, 2), 'utf8')
121
+ }
122
+ }