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,416 @@
1
+ /**
2
+ * dsh-session-guard — 自研会话门引擎(脱离 dsh-task-control,全量移植)。
3
+ *
4
+ * 真实锁定「session 推进」:复用 dsh runtime 自身的原语,不依赖任何外部会话门:
5
+ * - `agent.cancel({ kind:'user' }, { keepInbox:true })` —— 停住当前回合(中断推理/在途工具)。
6
+ * - `goals.pause(agent, { id, revision })` —— 停住同会话 goal,防 goal-round driver 再排。
7
+ * - `ctx.on('session/event')` —— 安全边界:tool/call 记 in-flight,tool/result 落地,
8
+ * assistant/message 记录 deferredTools,再落地延迟暂停(queueMicrotask)。
9
+ * - `agent.followup(createUserMessage({ content, source:{kind:'plugin',plugin} }))` —— 恢复续跑指令。
10
+ * - 暂停状态持久化(src/pause-store.js),不写 session log。
11
+ *
12
+ * 三粒度(对齐 task-control):
13
+ * force 立即中断推理+在途工具,记 interruptedTool,resume 需 confirm 选 rerun/skip
14
+ * safe + stop 在途工具跑完后再暂停(不中断推理则工具完成后落地)
15
+ * safe + wait 不中断推理,assistant/message 后记 deferredTools 再落地(默认)
16
+ *
17
+ * 设计为目标可单测:ctx / pauseStore / createUserMessage 全部依赖注入,
18
+ * 停/续/取消判定不依赖真实 dsh runtime;`getAgent(sessionId)` 经 `ctx.agents.get` 懒取。
19
+ */
20
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
21
+
22
+ /**
23
+ * @param {object} deps
24
+ * @param {object} deps.ctx host context(含 agents.get / get / logger,可选 goals)
25
+ * @param {ReturnType<import('./pause-store.js').createPauseStore>} deps.pauseStore 暂停状态存储
26
+ * @param {string} [deps.pluginId] followup 消息的 source.plugin(默认 'session-guard')
27
+ * @param {(content: object) => unknown} [deps.makeFollowupMessage] 恢复消息构造(默认 dsh-llm createUserMessage;测试注入)
28
+ */
29
+ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', makeFollowupMessage = createUserMessage }) {
30
+ const state = { inFlight: new Map(), pendingPause: new Map() }
31
+ const getAgent = (sessionId) => (ctx && typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined)
32
+
33
+ /** 会话门(dsh-task-control)当前是否可用(兼容探测;自研后始终视为可用)。 */
34
+ const taskControlAvailable = () => true
35
+
36
+ // ── in-flight 工具跟踪 ──────────────────────────────────────────────
37
+
38
+ function inflightOf(sessionId) {
39
+ let map = state.inFlight.get(sessionId)
40
+ if (map === undefined) {
41
+ map = new Map()
42
+ state.inFlight.set(sessionId, map)
43
+ }
44
+ return map
45
+ }
46
+
47
+ function latestInflight(sessionId) {
48
+ const map = inflightOf(sessionId)
49
+ const entries = [...map.values()]
50
+ return entries.length > 0 ? entries[entries.length - 1] : null
51
+ }
52
+
53
+ function clearInflight(sessionId) {
54
+ state.inFlight.delete(sessionId)
55
+ }
56
+
57
+ // ── 工具描述 / 结果查阅(resume 断点决策) ────────────────────────────
58
+
59
+ /** 描述工具目的:优先 model 写的 description,再 bash command,再 compact args。 */
60
+ function describeToolPurpose(info) {
61
+ if (info === undefined || info === null) return '未知工具'
62
+ const raw = info.arguments
63
+ const parseArgs = (text) => {
64
+ try {
65
+ return JSON.parse(text)
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+ const args = typeof raw === 'string' ? parseArgs(raw) : raw
71
+ if (args !== null && typeof args.description === 'string' && args.description.length > 0) return args.description
72
+ if (info.name === 'bash' && args !== null && typeof args.command === 'string') return `运行命令:${args.command}`
73
+ if (args !== null) {
74
+ try {
75
+ return JSON.stringify(args).slice(0, 120)
76
+ } catch {
77
+ /* ignore */
78
+ }
79
+ }
80
+ return `工具 ${info.name}`
81
+ }
82
+
83
+ function describeTool(info) {
84
+ return `${info?.name ?? '未知工具'}(${describeToolPurpose(info)})`
85
+ }
86
+
87
+ /** 在 session log 查一个中断工具的实际结果(kernel 会 drain 已启动工具到 tool/result)。 */
88
+ function findToolOutcome(agent, callId) {
89
+ if (!agent?.session?.events) return null
90
+ let outcome = null
91
+ for (const event of agent.session.events) {
92
+ if (event.type === 'tool/result') {
93
+ const message = event.data?.message ?? {}
94
+ const block = (Array.isArray(message.content) ? message.content : []).find((b) => b?.type === 'tool-result')
95
+ const id = block?.toolCallId ?? message.source?.callId
96
+ if (id === callId) {
97
+ outcome = {
98
+ hasResult: true,
99
+ isError: block?.isError === true,
100
+ abortedBeforeDispatch: event.data?.error?.code === 'ABORTED_BEFORE_DISPATCH',
101
+ content: block?.content ?? [],
102
+ }
103
+ }
104
+ } else if (event.type === 'user/message') {
105
+ const content = Array.isArray(event.data?.content) ? event.data.content : []
106
+ for (const block of content) {
107
+ if (block?.type === 'tool-result' && block.toolCallId === callId) {
108
+ outcome = { hasResult: true, isError: block.isError === true, abortedBeforeDispatch: false, content: block.content ?? [] }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ return outcome
114
+ }
115
+
116
+ function lastUserPrompt(agent) {
117
+ if (!agent?.session?.events) return null
118
+ for (let index = agent.session.events.length - 1; index >= 0; index -= 1) {
119
+ const event = agent.session.events[index]
120
+ if (event.type !== 'user/message') continue
121
+ if (event.data?.source?.kind !== 'user') continue
122
+ return event.data.content ?? null
123
+ }
124
+ return null
125
+ }
126
+
127
+ // ── 暂停状态读写 ────────────────────────────────────────────────────
128
+
129
+ function currentPause(id) {
130
+ return pauseStore.current(id)
131
+ }
132
+
133
+ function markPaused(id, resumeContent, forcedContext, deferredTools) {
134
+ const snapshot = {
135
+ sessionId: String(id),
136
+ paused: true,
137
+ resumeContent: resumeContent ?? null,
138
+ forced: forcedContext?.forced === true,
139
+ interruptedTool: forcedContext?.interruptedTool ?? null,
140
+ deferredTools: deferredTools ?? null,
141
+ updatedAt: Date.now(),
142
+ }
143
+ pauseStore.set(id, snapshot)
144
+ return snapshot
145
+ }
146
+
147
+ function clearPaused(id) {
148
+ pauseStore.clear(id)
149
+ }
150
+
151
+ // ── goal 暂停(防 goal-round driver 再排同会话轮次) ──────────────────
152
+
153
+ function pauseSessionGoal(agent) {
154
+ try {
155
+ const goals = ctx && typeof ctx.get === 'function' ? ctx.get('goals') : undefined
156
+ if (goals === undefined) return
157
+ const goal = goals.get(agent)
158
+ if (goal !== undefined && goal.phase === 'active') {
159
+ goals.pause(agent, { id: goal.id, revision: goal.revision })
160
+ }
161
+ } catch (e) {
162
+ ctx?.logger?.warn?.('[session-guard] goal pause failed: ' + String(e))
163
+ }
164
+ }
165
+
166
+ // ── 立即落地暂停 ────────────────────────────────────────────────────
167
+
168
+ /** 立即停止运行回合(agent.cancel 保 inbox)+ 停 goal + 持久化快照。 */
169
+ function applyPauseNow(id, resumeContent, forcedContext, deferredTools) {
170
+ state.pendingPause.delete(id)
171
+ const current = currentPause(id)
172
+ if (current.paused) return { kind: 'success', text: 'task is already paused' }
173
+ const agent = getAgent(id)
174
+ if (agent !== undefined && agent.status === 'running') agent.cancel({ kind: 'user' }, { keepInbox: true })
175
+ clearInflight(id)
176
+ if (agent !== undefined) pauseSessionGoal(agent)
177
+ markPaused(id, resumeContent, forcedContext, deferredTools)
178
+ return { kind: 'success', text: agent !== undefined && agent.status === 'running' ? 'task paused — the running turn was stopped' : 'task paused' }
179
+ }
180
+
181
+ // ── 暂停主逻辑(三粒度) ─────────────────────────────────────────────
182
+
183
+ /**
184
+ * 暂停一会话。opts: { mode:'safe'|'force', reason:'stop'|'wait' }。
185
+ * 默认 safe+wait(对齐 DEFAULT_SETTINGS)。返回 { kind, text, needConfirmation? }。
186
+ */
187
+ function pauseTask(sessionId, opts = {}) {
188
+ const agent = getAgent(sessionId)
189
+ if (agent === undefined) return { kind: 'error', text: 'no live agent for this session — nothing to pause' }
190
+ const current = currentPause(sessionId)
191
+ if (current.paused) return { kind: 'success', text: 'task is already paused' }
192
+ const mode = opts.mode ?? 'safe'
193
+ const reason = opts.reason ?? 'wait'
194
+ const resumeContent = agent.status === 'running' ? lastUserPrompt(agent) : null
195
+
196
+ if (mode === 'force') {
197
+ const interruptedTool = latestInflight(sessionId)
198
+ if (agent.status === 'running') agent.cancel({ kind: 'user' }, { keepInbox: true })
199
+ clearInflight(sessionId)
200
+ pauseSessionGoal(agent)
201
+ markPaused(sessionId, resumeContent, {
202
+ forced: true,
203
+ interruptedTool: interruptedTool ? { name: interruptedTool.name, arguments: interruptedTool.arguments, callId: interruptedTool.callId } : null,
204
+ })
205
+ return {
206
+ kind: 'success',
207
+ text: interruptedTool !== null
208
+ ? `task force-paused — interrupted tool ${interruptedTool.name}(预期目的:${describeToolPurpose(interruptedTool)}),可能已部分执行`
209
+ : 'task force-paused — interrupted the running turn',
210
+ }
211
+ }
212
+
213
+ // safe mode —— 延迟到安全边界
214
+ if (agent.status === 'running' && inflightOf(sessionId).size > 0) {
215
+ state.pendingPause.set(sessionId, { resumeContent, mode: 'safe', reason })
216
+ return { kind: 'success', text: 'task pausing — waiting for the running tool to finish (safe boundary), trace keeps recording until then' }
217
+ }
218
+ if (agent.status === 'running' && reason === 'wait') {
219
+ state.pendingPause.set(sessionId, { resumeContent, mode: 'safe', reason: 'wait' })
220
+ return { kind: 'success', text: 'task pausing — waiting for the current reasoning to complete before pausing' }
221
+ }
222
+ return applyPauseNow(sessionId, resumeContent, { forced: false, interruptedTool: null })
223
+ }
224
+
225
+ // ── 恢复主逻辑 ───────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * 恢复一会话(从暂停点继续,session log 即 trace,不整体重发)。
229
+ * opts: { confirm:boolean, choice:'rerun'|'skip' }。
230
+ */
231
+ function resumeTask(sessionId, opts = {}) {
232
+ const agent = getAgent(sessionId)
233
+ if (agent === undefined) return { kind: 'error', text: 'no live agent for this session' }
234
+ state.pendingPause.delete(sessionId)
235
+ const current = currentPause(sessionId)
236
+ if (!current.paused) return { kind: 'success', text: 'no paused task to resume' }
237
+ const followup = (blocks) => agent.followup(makeFollowupMessage({ content: blocks, source: { kind: 'plugin', plugin: pluginId } }))
238
+
239
+ if (current.forced) {
240
+ const tool = current.interruptedTool
241
+ if (tool !== null) {
242
+ if (opts?.confirm !== true) {
243
+ return { kind: 'error', needConfirmation: true, text: `需要确认:上次暂停时工具 ${describeTool(tool)} 没有执行完成,将重新执行。请选择:重新执行该工具 / 跳过该工具 / 保持暂停。` }
244
+ }
245
+ const outcome = findToolOutcome(agent, tool.callId)
246
+ clearPaused(sessionId)
247
+ if (outcome !== null && !outcome.isError) {
248
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 实际已执行完成(结果见上方上下文)。请基于该结果继续执行,不要重复执行该工具。` }])
249
+ return { kind: 'success', text: `task resumed — tool ${tool.name} had actually completed, continuing` }
250
+ }
251
+ if (outcome !== null && outcome.abortedBeforeDispatch) {
252
+ if (opts?.choice === 'skip') {
253
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 未及执行(无副作用),你选择跳过。请直接继续后续工作。` }])
254
+ return { kind: 'success', text: `task resumed — skipped tool ${tool.name}` }
255
+ }
256
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 未及执行(无副作用)。请执行该工具调用,然后继续任务。` }])
257
+ return { kind: 'success', text: `task resumed — re-executing tool ${tool.name}` }
258
+ }
259
+ if (opts?.choice === 'skip') {
260
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 没有执行完成(可能已部分执行,状态未知)。你选择跳过该工具:请基于已有上下文继续任务,不再执行该工具。` }])
261
+ return { kind: 'success', text: `task resumed — skipped tool ${tool.name}` }
262
+ }
263
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 没有执行完成(可能已部分执行并产生副作用,状态未知)。你选择重新执行:请先评估/清理该工具可能产生的部分副作用,再重新执行该工具调用,然后继续任务。` }])
264
+ return { kind: 'success', text: `task resumed — re-executing interrupted tool ${tool.name}` }
265
+ }
266
+ clearPaused(sessionId)
267
+ followup([{ type: 'text', text: '任务已恢复。请基于以上上下文(暂停点之前的完整执行记录)继续执行任务。' }])
268
+ return { kind: 'success', text: 'task resumed — continuing from the latest trace' }
269
+ }
270
+
271
+ // safe pause
272
+ const deferred = Array.isArray(current.deferredTools) ? current.deferredTools : []
273
+ if (deferred.length > 0) {
274
+ if (opts?.confirm !== true) {
275
+ return { kind: 'error', needConfirmation: true, text: `需要确认:上次暂停发生在推理完成后、工具执行前,以下工具未及执行(无副作用):${deferred.map(describeTool).join('、')}。请选择:重新执行 / 跳过 / 保持暂停。` }
276
+ }
277
+ clearPaused(sessionId)
278
+ const rerun = deferred.filter((tool) => {
279
+ const outcome = findToolOutcome(agent, tool.callId)
280
+ return outcome === null || outcome.abortedBeforeDispatch === true
281
+ })
282
+ if (rerun.length === 0) {
283
+ followup([{ type: 'text', text: '任务已恢复。上次暂停时待执行的工具均已实际执行完成,请基于以上结果继续执行,不要重复执行。' }])
284
+ return { kind: 'success', text: 'task resumed — deferred tools had actually completed' }
285
+ }
286
+ if (opts?.choice === 'skip') {
287
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时以下工具未及执行(无副作用),你选择跳过:${rerun.map(describeTool).join('、')}。请直接继续后续工作。` }])
288
+ return { kind: 'success', text: 'task resumed — skipped deferred tools' }
289
+ }
290
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时以下工具未及执行(无副作用),请执行这些工具调用,然后继续任务:${rerun.map(describeTool).join('、')}。` }])
291
+ return { kind: 'success', text: 'task resumed — re-executing deferred tools' }
292
+ }
293
+
294
+ clearPaused(sessionId)
295
+ if (agent.status === 'running') {
296
+ return { kind: 'success', text: 'task resumed — the task was still actually running; execution results keep flowing' }
297
+ }
298
+ followup([{ type: 'text', text: '任务已恢复。请基于以上上下文(暂停点之前的完整执行记录)从暂停点继续执行,不要重复已完成的工作。' }])
299
+ return { kind: 'success', text: 'task resumed — continuing from the pause point' }
300
+ }
301
+
302
+ // ── 取消 ─────────────────────────────────────────────────────────────
303
+
304
+ /** 立即终止当前回合,回报被中断工具的目的/副作用风险。 */
305
+ function cancelTask(sessionId) {
306
+ const agent = getAgent(sessionId)
307
+ if (agent === undefined) return { kind: 'error', text: 'no live agent for this session' }
308
+ const current = currentPause(sessionId)
309
+ if (current.paused) clearPaused(sessionId)
310
+ state.pendingPause.delete(sessionId)
311
+ let interrupted = null
312
+ if (agent.status === 'running') {
313
+ interrupted = latestInflight(sessionId)
314
+ agent.cancel({ kind: 'user' }, { keepInbox: true })
315
+ clearInflight(sessionId)
316
+ }
317
+ if (interrupted !== null) {
318
+ return { kind: 'success', text: `task cancelled — 已立即终止正在执行的工具 ${interrupted.name}。其预期目的:${describeToolPurpose(interrupted)}。请检查该操作是否产生了副作用(文件修改、进程、网络等)。` }
319
+ }
320
+ return { kind: 'success', text: 'task cancelled' }
321
+ }
322
+
323
+ // ── 安全边界:session/event 监听落点 ─────────────────────────────────
324
+
325
+ function scheduleDeferredPause(sessionId, resumeContent, deferredTools) {
326
+ queueMicrotask(() => {
327
+ try {
328
+ const agent = getAgent(sessionId)
329
+ if (agent === undefined) {
330
+ state.pendingPause.delete(sessionId)
331
+ return
332
+ }
333
+ applyPauseNow(sessionId, resumeContent, { forced: false, interruptedTool: null }, deferredTools)
334
+ } catch (e) {
335
+ ctx?.logger?.warn?.('[session-guard] deferred pause failed: ' + String(e))
336
+ state.pendingPause.delete(sessionId)
337
+ }
338
+ })
339
+ }
340
+
341
+ function tryApplyPending(sessionId) {
342
+ const pending = state.pendingPause.get(sessionId)
343
+ if (pending === undefined) return
344
+ if (pending.mode !== 'safe') return
345
+ if (inflightOf(sessionId).size > 0) return
346
+ scheduleDeferredPause(sessionId, pending.resumeContent ?? null, pending.deferredTools ?? null)
347
+ }
348
+
349
+ /** 会话事件监听:跟踪在途工具 + 在安全边界落地延迟暂停。 */
350
+ function handleEvent(session, event) {
351
+ const sessionId = session?.id
352
+ if (typeof sessionId !== 'string') return
353
+ if (event.type === 'tool/call') {
354
+ const info = { name: event.data?.name ?? 'tool', arguments: event.data?.arguments ?? null, callId: event.data?.callId ?? 'unknown' }
355
+ if (typeof event.data?.callId === 'string') inflightOf(sessionId).set(event.data.callId, info)
356
+ return
357
+ }
358
+ if (event.type === 'tool/result') {
359
+ const callId = event.data?.message?.source?.callId ?? event.data?.message?.content?.[0]?.toolCallId
360
+ if (typeof callId === 'string') inflightOf(sessionId).delete(callId)
361
+ tryApplyPending(sessionId)
362
+ return
363
+ }
364
+ if (event.type === 'assistant/message') {
365
+ const pending = state.pendingPause.get(sessionId)
366
+ if (pending !== undefined && pending.mode === 'safe' && pending.reason === 'wait') {
367
+ const content = Array.isArray(event.data?.message?.content) ? event.data.message.content : []
368
+ const calls = content.filter((block) => block?.type === 'tool-call').map((block) => ({
369
+ name: block.name ?? 'tool',
370
+ arguments: block.arguments ?? null,
371
+ callId: block.id ?? 'unknown',
372
+ }))
373
+ if (calls.length > 0) pending.deferredTools = calls
374
+ }
375
+ tryApplyPending(sessionId)
376
+ return
377
+ }
378
+ if (event.type === 'user/message') {
379
+ const content = Array.isArray(event.data?.content) ? event.data.content : []
380
+ for (const block of content) {
381
+ if (block?.type === 'tool-result' && typeof block.toolCallId === 'string') inflightOf(sessionId).delete(block.toolCallId)
382
+ }
383
+ tryApplyPending(sessionId)
384
+ return
385
+ }
386
+ }
387
+
388
+ // ── 状态读取 ─────────────────────────────────────────────────────────
389
+
390
+ function sessionState(sessionId) {
391
+ const agent = getAgent(sessionId)
392
+ const current = currentPause(sessionId)
393
+ return {
394
+ sessionId: String(sessionId),
395
+ status: agent === undefined ? 'offline' : agent.status,
396
+ paused: current.paused === true,
397
+ forced: current.forced === true,
398
+ interruptedTool: current.interruptedTool ?? null,
399
+ deferredTools: current.deferredTools ?? null,
400
+ resumeContent: current.resumeContent ?? null,
401
+ }
402
+ }
403
+
404
+ return {
405
+ pause: pauseTask,
406
+ resume: resumeTask,
407
+ cancel: cancelTask,
408
+ state: sessionState,
409
+ handleEvent,
410
+ taskControlAvailable,
411
+ /** 当前 in-flight 工具数(测试用)。 */
412
+ _inflightCount: (id) => inflightOf(id).size,
413
+ /** 是否挂起了延迟暂停(测试用)。 */
414
+ _pendingCount: () => state.pendingPause.size,
415
+ }
416
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * dsh-session-guard — 自研会话门暂停状态持久化(脱离 dsh-task-control)。
3
+ *
4
+ * 与 queueLock(src/store.js,队列锁)分离:这里是「真暂停」的持久化快照,
5
+ * 仿 task-control 的状态模型,但独立子目录 /pause 避免与队列锁文件互相污染。
6
+ *
7
+ * 暂停/锁定状态仍**不写 session log**(harness 持久化 reader 只有已知事件集,
8
+ * 自定义 `session-guard/*` 事件会导致重启后会话无法加载),走插件自有 JSON 文件:
9
+ * `$DSH_HOME/.dsh/session-guard/pause/<sessionId>.json`(`DSH_SESSION_GUARD_PAUSE_DIR`
10
+ * 可覆盖根目录)。原子写:tmp + rename。
11
+ */
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+ import { storeRoot } from './store.js'
15
+
16
+ /** 会话 id → 安全文件名(与队列锁同规则)。 */
17
+ export function encodeSessionId(id) {
18
+ return String(id).replace(/[^a-zA-Z0-9._-]/g, '_')
19
+ }
20
+
21
+ /** 自研暂停状态根目录(独立于队列锁)。 */
22
+ export function pauseStateRoot() {
23
+ return (
24
+ process.env.DSH_SESSION_GUARD_PAUSE_DIR ||
25
+ join(storeRoot(), 'pause')
26
+ )
27
+ }
28
+
29
+ /** 每会话暂停状态文件路径。 */
30
+ export function pauseFilePath(id) {
31
+ return join(pauseStateRoot(), `${encodeSessionId(id)}.json`)
32
+ }
33
+
34
+ /** 未暂停基线快照。 */
35
+ export function idlePauseState(id) {
36
+ return {
37
+ sessionId: String(id),
38
+ paused: false,
39
+ resumeContent: null,
40
+ forced: false,
41
+ interruptedTool: null,
42
+ deferredTools: null,
43
+ updatedAt: null,
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 创建暂停状态存储(内存缓存 + 磁盘持久化,原子写)。
49
+ * 独立于队列锁 store,字段形态仿 task-control(paused/forced/interruptedTool/deferredTools)。
50
+ */
51
+ export function createPauseStore() {
52
+ const cache = new Map()
53
+
54
+ function read(id) {
55
+ if (cache.has(id)) return cache.get(id)
56
+ try {
57
+ const f = pauseFilePath(id)
58
+ if (!existsSync(f)) return null
59
+ const v = JSON.parse(readFileSync(f, 'utf8'))
60
+ cache.set(id, v)
61
+ return v
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ return {
68
+ /** 读一会话暂停态;无记录返回 null(调用方 fallback idlePauseState)。 */
69
+ get(id) {
70
+ return read(id)
71
+ },
72
+ /** 原子写。 */
73
+ set(id, value) {
74
+ cache.set(id, value)
75
+ try {
76
+ const f = pauseFilePath(id)
77
+ mkdirSync(dirname(f), { recursive: true })
78
+ const tmp = `${f}.tmp`
79
+ writeFileSync(tmp, JSON.stringify(value, null, 2))
80
+ renameSync(tmp, f)
81
+ } catch (e) {
82
+ // 落盘失败仅影响重启恢复,内存态仍可用。
83
+ console.error(`[session-guard] pause store write failed: ${String(e && e.message || e)}`)
84
+ }
85
+ },
86
+ /** 清除(resume/cancel 后)。 */
87
+ clear(id) {
88
+ cache.delete(id)
89
+ try {
90
+ rmSync(pauseFilePath(id), { force: true })
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ },
95
+ /** 当前有效快照(无记录时 idle 基线)。 */
96
+ current(id) {
97
+ const v = read(id)
98
+ return v !== null && typeof v === 'object' ? v : idlePauseState(id)
99
+ },
100
+ }
101
+ }