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/README.md +219 -0
- package/README.zh.md +183 -0
- package/cordis.patch.yml +8 -0
- package/dsh.plugin.json +27 -0
- package/lib/index.js +14571 -0
- package/package.json +87 -0
- package/scripts/build.sh +64 -0
- package/src/core.ts +396 -0
- package/src/index.ts +30 -0
- package/src/monitor.ts +320 -0
- package/src/registry.ts +122 -0
- package/src/tools.ts +1005 -0
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
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -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
|
+
}
|