dsh-mini-remote 0.1.8 → 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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 导航用的会话元数据缓存。
3
+ *
4
+ * 这里只保存 sessionQuery.listSessions() 返回记录中的 header 和两个可用性标志,
5
+ * 不保存会话正文。用途是让 DSH 重启后导航可以先显示上次已知的工作区关系,
6
+ * 再由后台完整扫描校正。
7
+ */
8
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
9
+ import { dirname } from 'node:path'
10
+
11
+ const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000
12
+ const DEFAULT_MAX = 10_000
13
+
14
+ function validRecord(row) {
15
+ return Boolean(
16
+ row && typeof row === 'object'
17
+ && row.header && typeof row.header.id === 'string' && row.header.id
18
+ && Number.isFinite(row.header.createdAt),
19
+ )
20
+ }
21
+
22
+ export function createSessionMetaCache({
23
+ file, ttl = DEFAULT_TTL_MS, max = DEFAULT_MAX, debounceMs = 500, now = Date.now,
24
+ }) {
25
+ const rows = new Map()
26
+ try {
27
+ const raw = JSON.parse(readFileSync(file, 'utf8'))
28
+ const at = now()
29
+ for (const row of Array.isArray(raw) ? raw : Object.values(raw ?? {})) {
30
+ if (!validRecord(row) || typeof row.at !== 'number' || at - row.at > ttl) continue
31
+ rows.set(row.header.id, { ...row, at: row.at })
32
+ }
33
+ } catch {
34
+ // 第一次运行或缓存损坏都从空缓存开始,不能影响插件启动。
35
+ }
36
+
37
+ let timer = null
38
+ let dirty = false
39
+ function save() {
40
+ timer = null
41
+ if (!dirty) return
42
+ dirty = false
43
+ try {
44
+ mkdirSync(dirname(file), { recursive: true })
45
+ const tmp = `${file}.tmp`
46
+ writeFileSync(tmp, JSON.stringify([...rows.values()]))
47
+ renameSync(tmp, file)
48
+ } catch {
49
+ // 缓存写失败只影响下次冷启动,不影响当前导航。
50
+ }
51
+ }
52
+ function schedule() {
53
+ dirty = true
54
+ if (timer) return
55
+ timer = setTimeout(save, debounceMs)
56
+ timer.unref?.()
57
+ }
58
+
59
+ return {
60
+ all() {
61
+ const at = now()
62
+ return [...rows.values()]
63
+ .filter((row) => at - row.at <= ttl)
64
+ .map(({ at: _at, ...row }) => row)
65
+ },
66
+
67
+ replace(records) {
68
+ rows.clear()
69
+ const at = now()
70
+ for (const record of records ?? []) {
71
+ if (!validRecord(record)) continue
72
+ rows.set(record.header.id, {
73
+ header: record.header,
74
+ live: Boolean(record.live),
75
+ persisted: Boolean(record.persisted),
76
+ at,
77
+ })
78
+ }
79
+ while (rows.size > max) rows.delete(rows.keys().next().value)
80
+ schedule()
81
+ },
82
+
83
+ upsert(record) {
84
+ if (!validRecord(record)) return
85
+ rows.set(record.header.id, {
86
+ header: record.header,
87
+ live: Boolean(record.live),
88
+ persisted: Boolean(record.persisted),
89
+ at: now(),
90
+ })
91
+ while (rows.size > max) rows.delete(rows.keys().next().value)
92
+ schedule()
93
+ },
94
+
95
+ clear() {
96
+ rows.clear()
97
+ schedule()
98
+ },
99
+ }
100
+ }
package/lib/store.js CHANGED
@@ -6,6 +6,12 @@
6
6
  */
7
7
  import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
8
8
  import { join, dirname } from 'node:path'
9
+ import {
10
+ applyTrajectoryPatch,
11
+ capTrajectory,
12
+ mergeTrajectory,
13
+ TRAJECTORY_MAX_ENTRIES,
14
+ } from './trajectory.js'
9
15
 
10
16
  export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
11
17
  const state = {
@@ -21,6 +27,18 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
21
27
  historyBySession: {}, // sessionId -> [{ role, text, timestamp, sessionId }]
22
28
  /** 手机当前遥控的那个会话。 */
23
29
  boundSessionId: null,
30
+ /**
31
+ * 「进阶设置(电脑端界面)」的开关。**默认关**。
32
+ *
33
+ * 为什么默认关:打开它 = 把这个令牌的权限**从「我们那张手机页暴露的那点事」
34
+ * 扩到整个电脑端设置面**(模型配置、凭据页、插件装卸、会话记录……)。
35
+ * 那是能力的大幅扩张,不该在用户不知情的时候发生——所以要他自己开,
36
+ * 而且界面上会把后果写清楚。
37
+ *
38
+ * 关着的时候那条代理路由**根本不存在**(见 server.js 里的判断),
39
+ * 不是「前端藏起来、后端还开着」。
40
+ */
41
+ mirrorEnabled: false,
24
42
  /** 手机会话在线状态,用于决定要不要发外部推送。 */
25
43
  presence: 'foreground',
26
44
  lastSeen: 0,
@@ -41,6 +59,24 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
41
59
  * 所以刷新页面看到的是「当前这一句」,而不是上一次的残留。
42
60
  */
43
61
  thoughtBySession: {},
62
+ /**
63
+ * 执行轨迹(「完整」模式里那一块过程):sessionId ->
64
+ * `{ entries, turns, currentTurn, patches, dropped, seq }`。**故意不落盘**。
65
+ *
66
+ * 为什么连它一起不落盘(照 liveBySession 的理由,但这里更硬):实测轨迹正文是
67
+ * 回答正文的 90.7 倍(单轮最高约 946KB),而落盘那份是**每 500 毫秒重写整份 JSON**。
68
+ * 灌进去等于每半秒重写一遍几兆的文件。它本来就是「刚发生的过程」,重启后从会话记录里
69
+ * 重放即可(见 lib/index.js 读历史那一段),聊天记录才是要留的那份。
70
+ *
71
+ * - `entries`:扁平的条目数组,每条自带 turn/step;上限 TRAJECTORY_MAX_ENTRIES 条,从尾部留。
72
+ * - `turns`:轮号 -> { state, reason },只留「还有条目的轮」和「当前这一轮」。
73
+ * - `patches`:没认领到的补丁(插件中途起来时,那次调用的 tool/call 没亲眼看见,
74
+ * 但结果看见了)。少了它,手机上的那一行会一直停在「正在跑」。
75
+ * - `seq`:这个会话的帧序号,客户端靠它认出「我漏了几帧」。
76
+ */
77
+ trajectoryBySession: {},
78
+ /** 当前父会话名下正在运行的子智能体:parentId -> childId -> true。只在内存中。 */
79
+ subagentRunningBySession: {},
44
80
  }
45
81
 
46
82
  /** 已知会话的元信息:sessionId -> { id, title, running, lastActivity, seq } */
@@ -99,6 +135,11 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
99
135
  * (`sessionQuery.readSession`),而 store 拿不到 ctx,所以由插件注入一个回调。
100
136
  * 回调返回 `{ entries, latest, pending }`;没读过这个会话时返回 null。
101
137
  *
138
+ * **同一份记录里还带着执行轨迹**(`trajectory` / `trajectoryDropped`):轨迹和聊天记录
139
+ * 是同一批原始事件的两面,读一次日志顺手把两面都重放出来,别为它再读一遍盘。
140
+ * 轨迹那份**已经在插件那边按上限截过**(120 条)再放这儿,否则 20 个会话的缓存
141
+ * 会各压着几兆。
142
+ *
102
143
  * **为什么不把读出来的历史塞进 `state.historyBySession`**:那一份是要落盘的,
103
144
  * 而且是 500 毫秒写一次整个 JSON。几百条历史灌进去,等于每半秒重写一遍几百 KB
104
145
  * 的文件。所以完整历史只活在内存里,落盘的仍然是「插件亲眼看过的事件」。
@@ -190,6 +231,92 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
190
231
  return t ? { text: t.text, at: t.at } : null
191
232
  }
192
233
 
234
+ /**
235
+ * 「当前这一轮」的哨兵。用 Symbol 而不是 null:`turn` 本身可能是 null
236
+ * (事件里没带轮号),拿 null 当哨兵就分不清「还没轮到过」和「这一轮没有轮号」。
237
+ */
238
+ const NO_TURN = Symbol('no-turn')
239
+
240
+ /** 轨迹缓冲的那一格。第一次碰到才建,删会话时一起丢。 */
241
+ function trajectorySlot(sessionId) {
242
+ let buf = state.trajectoryBySession[sessionId]
243
+ if (!buf) {
244
+ buf = state.trajectoryBySession[sessionId] = {
245
+ entries: [],
246
+ turns: new Map(),
247
+ currentTurn: NO_TURN,
248
+ patches: new Map(),
249
+ dropped: 0,
250
+ seq: 0,
251
+ }
252
+ }
253
+ return buf
254
+ }
255
+
256
+ /**
257
+ * 轨迹缓冲也按会话数收着点:插件会看到**所有**主会话的事件,不设上限的话,
258
+ * 一个跑了几十上百个会话的晚上就是几十份几 MB 的条目压在内存里。
259
+ * 丢掉的是「没在看的那些会话」刚发生的那一段——老的都在会话记录里(重放那条路),
260
+ * 手机切过去照样能拉出来。
261
+ */
262
+ function trimTrajectory(keepId) {
263
+ const ids = Object.keys(state.trajectoryBySession)
264
+ if (ids.length <= maxSessions) return
265
+ ids.sort((a, b) => (sessions.get(b)?.seq ?? 0) - (sessions.get(a)?.seq ?? 0))
266
+ for (const id of ids.slice(maxSessions)) {
267
+ if (id === keepId) continue
268
+ delete state.trajectoryBySession[id]
269
+ }
270
+ }
271
+
272
+ /** 找那条条目(结果总是紧跟着它的调用回来,所以从后往前找)。 */
273
+ function trajectoryEntryOf(buf, id) {
274
+ for (let i = buf.entries.length - 1; i >= 0; i -= 1) {
275
+ if (buf.entries[i]?.id === id) return buf.entries[i]
276
+ }
277
+ return null
278
+ }
279
+
280
+ /**
281
+ * 轮状态表只留「还有条目的轮」+「当前这一轮」。
282
+ * 不留的话,一个长会话跑几百轮就攒几百个 key;而当前这一轮哪怕一条条目都还没有
283
+ * (turn/start 刚到)也得留——手机靠它把那一组先立起来。
284
+ */
285
+ function pruneTrajectoryTurns(buf) {
286
+ if (!buf.turns.size) return
287
+ const live = new Set(buf.entries.map((e) => e.turn))
288
+ for (const turn of [...buf.turns.keys()]) {
289
+ if (live.has(turn) || turn === buf.currentTurn) continue
290
+ buf.turns.delete(turn)
291
+ }
292
+ }
293
+
294
+ /** 内存那份轨迹按轮分组(形状和重放那份、和增量帧要的形状一样)。 */
295
+ function liveTrajectoryTurns(buf) {
296
+ const out = []
297
+ const byTurn = new Map()
298
+ for (const entry of buf.entries) {
299
+ let group = byTurn.get(entry.turn)
300
+ if (!group) {
301
+ const st = buf.turns.get(entry.turn)
302
+ group = {
303
+ turn: entry.turn,
304
+ state: st?.state ?? 'running',
305
+ reason: st?.reason ?? null,
306
+ entries: [],
307
+ }
308
+ byTurn.set(entry.turn, group)
309
+ out.push(group)
310
+ }
311
+ group.entries.push(entry)
312
+ }
313
+ for (const [turn, st] of buf.turns) {
314
+ if (byTurn.has(turn)) continue
315
+ out.push({ turn, state: st.state, reason: st.reason ?? null, entries: [] })
316
+ }
317
+ return out
318
+ }
319
+
193
320
  /**
194
321
  * 只保留最近碰过的若干会话的数据,免得这个 JSON 文件无限长大。
195
322
  * 被丢掉的会话切过去会显示「还没有回复」——**这比显示别人的回答强**。
@@ -203,6 +330,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
203
330
  delete state.latestBySession[id]
204
331
  delete state.liveBySession[id]
205
332
  delete state.thoughtBySession[id]
333
+ delete state.trajectoryBySession[id]
206
334
  }
207
335
  }
208
336
 
@@ -237,6 +365,9 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
237
365
  }
238
366
  }
239
367
  if (raw.boundSessionId) state.boundSessionId = raw.boundSessionId
368
+ // 进阶模式:**只有明确是 true 才算开**。缺失、损坏、写成别的值一律当关——
369
+ // 这个开关关系到权限大小,读不出来时必须往「关」那边倒。
370
+ if (raw.mirrorEnabled === true) state.mirrorEnabled = true
240
371
  }
241
372
  } catch {
242
373
  // 首次运行没有文件,或文件损坏——都不该让插件起不来。
@@ -256,6 +387,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
256
387
  latestBySession: state.latestBySession,
257
388
  historyBySession: state.historyBySession,
258
389
  boundSessionId: state.boundSessionId,
390
+ mirrorEnabled: state.mirrorEnabled,
259
391
  }))
260
392
  renameSync(tmp, file)
261
393
  } catch {
@@ -291,6 +423,40 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
291
423
  s.running = running
292
424
  },
293
425
 
426
+ /**
427
+ * 开/关「进阶设置(电脑端界面)」。**立即落盘**,不等那 500 毫秒的合并写。
428
+ *
429
+ * 为什么破例:这是个权限开关。用户开了之后马上就会去点那个入口,
430
+ * 万一这中间进程没了,重启后应当还是他最后选的那个状态——
431
+ * 合并写那 500 毫秒的窗口在这里不值得省。
432
+ */
433
+ setMirrorEnabled(enabled) {
434
+ state.mirrorEnabled = enabled === true
435
+ try {
436
+ mkdirSync(dirname(file), { recursive: true })
437
+ const tmp = `${file}.tmp`
438
+ writeFileSync(tmp, JSON.stringify({
439
+ latestBySession: state.latestBySession,
440
+ historyBySession: state.historyBySession,
441
+ boundSessionId: state.boundSessionId,
442
+ mirrorEnabled: state.mirrorEnabled,
443
+ }))
444
+ renameSync(tmp, file)
445
+ } catch {
446
+ // 落盘失败不影响本次运行——内存里那个值照常生效。
447
+ }
448
+ return state.mirrorEnabled
449
+ },
450
+
451
+ setSubagentRunning(parentSessionId, childSessionId, running) {
452
+ if (!parentSessionId || !childSessionId) return
453
+ const group = state.subagentRunningBySession[parentSessionId]
454
+ ?? (state.subagentRunningBySession[parentSessionId] = {})
455
+ if (running) group[childSessionId] = true
456
+ else delete group[childSessionId]
457
+ if (!Object.keys(group).length) delete state.subagentRunningBySession[parentSessionId]
458
+ },
459
+
294
460
  /** 最近活跃的会话——手机没绑定时默认遥控它。 */
295
461
  mostRecentSession,
296
462
 
@@ -318,7 +484,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
318
484
  * interrupted 是「这一轮被中止了,下面只是已经吐出来的那半句」。
319
485
  * 手机要把它标出来——不标的话,按了停止之后冒出来的半句话看着像模型答崩了。
320
486
  */
321
- pushReply({ text, sessionId, reason, interrupted, timestamp }) {
487
+ pushReply({ text, sessionId, reason, interrupted, timestamp, turn }) {
322
488
  // 同上:事件带过来的 `time` 优先。合成时两边的钟不一样,同一条回复就会
323
489
  // 既从硬盘来一遍、又从手里这份来一遍,手机上显示两遍。
324
490
  const at = timestamp ?? Date.now()
@@ -329,12 +495,14 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
329
495
  }
330
496
  pushHistory(sessionId, {
331
497
  role: 'assistant', text, timestamp: at, sessionId, interrupted: interrupted === true,
498
+ // 这条回复是第几轮的——完整模式把轨迹块插回对话原位要用(见 lib/history.js)。
499
+ turn: turn ?? null,
332
500
  })
333
501
  persist()
334
502
  },
335
503
 
336
504
  /** 用户(手机上或电脑上)发出了一条指令。 */
337
- pushUser({ text, sessionId, id, timestamp }) {
505
+ pushUser({ text, sessionId, id, timestamp, turn }) {
338
506
  // id 要留着:排队中的指令靠它和 agent 收件箱里的那一条对上号,
339
507
  // 界面上才能标出「这条还在排队」、也才能撤掉它。
340
508
  //
@@ -344,6 +512,9 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
344
512
  // 同一条指令显示两遍(2026-09-27 用户实机报的,实测两份差 1 毫秒)。
345
513
  pushHistory(sessionId, {
346
514
  role: 'user', text, timestamp: timestamp ?? Date.now(), sessionId, id,
515
+ // 第几轮的指令——完整模式把轨迹块插回对话原位要用。手机自己发的那条
516
+ // (onInstruction 那路)发出去时轮还没开,轮号留空即可。
517
+ turn: turn ?? null,
347
518
  })
348
519
  persist()
349
520
  },
@@ -358,7 +529,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
358
529
  * 时间戳同样用事件自己的 `time`:硬盘重放出来那份(lib/history.js 里的 notice)
359
530
  * 用的也是它,两边同一个钟才合成得成一条,而不是显示两遍。
360
531
  */
361
- pushNotice({ sessionId, text, summary, senderSessionId, timestamp }) {
532
+ pushNotice({ sessionId, text, summary, senderSessionId, timestamp, turn }) {
362
533
  pushHistory(sessionId, {
363
534
  role: 'notice',
364
535
  text,
@@ -366,6 +537,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
366
537
  senderSessionId: senderSessionId ?? '',
367
538
  timestamp: timestamp ?? Date.now(),
368
539
  sessionId,
540
+ turn: turn ?? null,
369
541
  })
370
542
  persist()
371
543
  },
@@ -460,6 +632,101 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
460
632
  return state.presence === 'foreground' && Date.now() - state.lastSeen < 60_000
461
633
  },
462
634
 
635
+ /**
636
+ * 实时轨迹来了一批变化:落进内存缓冲,并给出**这一批的增量帧**。
637
+ *
638
+ * 为什么是增量而不是整份:实测轨迹正文是回答正文的 90.7 倍(单轮最高约 946KB),
639
+ * 每来一次工具事件就把一整份重新发一遍(现在的 state 快照就是这么发的),
640
+ * 手机上会被压垮、隧道里流量也不可接受。所以这里只回「新加的那几条 + 改了的那几笔」。
641
+ *
642
+ * **不 persist()**:理由见 state.trajectoryBySession 那段。
643
+ *
644
+ * @returns {null|{turn:number|null, state:string|null, reason:string|null, seq:number,
645
+ * add:Array<object>, update:Array<object>}} null = 什么都没变,不必推
646
+ */
647
+ applyTrajectory(sessionId, change) {
648
+ if (!sessionId || !change) return null
649
+ const entries = Array.isArray(change.entries) ? change.entries : []
650
+ const updates = Array.isArray(change.updates) ? change.updates : []
651
+ const turnState = typeof change.state === 'string' && change.state ? change.state : null
652
+ if (!entries.length && !updates.length && !turnState) return null
653
+
654
+ const buf = trajectorySlot(sessionId)
655
+ for (const entry of entries) buf.entries.push(entry)
656
+ for (const patch of updates) {
657
+ if (!patch?.id) continue
658
+ const hit = trajectoryEntryOf(buf, patch.id)
659
+ if (hit) applyTrajectoryPatch(hit, patch)
660
+ // 没认领到就先存着(见 state.trajectoryBySession 里 patches 那条):
661
+ // 插件可能是中途起来的——那次调用的 tool/call 没亲眼看见,结果却看见了。
662
+ // 直接丢掉的话,手机上那一行会一直停在「正在跑」。
663
+ else buf.patches.set(patch.id, { ...(buf.patches.get(patch.id) ?? {}), ...patch })
664
+ }
665
+ while (buf.patches.size > TRAJECTORY_MAX_ENTRIES) {
666
+ buf.patches.delete(buf.patches.keys().next().value)
667
+ }
668
+ if (turnState) {
669
+ buf.turns.set(change.turn, { state: turnState, reason: change.reason ?? null })
670
+ buf.currentTurn = change.turn
671
+ }
672
+ // 从尾部留:手机上看的是刚发生的过程,最早的先走。
673
+ if (buf.entries.length > TRAJECTORY_MAX_ENTRIES) {
674
+ const cut = buf.entries.length - TRAJECTORY_MAX_ENTRIES
675
+ buf.entries.splice(0, cut)
676
+ buf.dropped += cut
677
+ }
678
+ pruneTrajectoryTurns(buf)
679
+ trimTrajectory(sessionId)
680
+ buf.seq += 1
681
+
682
+ return {
683
+ turn: change.turn ?? null,
684
+ state: turnState,
685
+ reason: turnState ? (change.reason ?? null) : null,
686
+ seq: buf.seq,
687
+ add: entries,
688
+ update: updates,
689
+ }
690
+ },
691
+
692
+ /**
693
+ * 这个会话的轨迹视图:内存那份(刚发生的)+ 会话记录里重放出来的那份(更完整的),
694
+ * 按 id 合成一组一轮。上限 120 条、从尾部留;「这不是全部」由 truncated/note 说出去。
695
+ *
696
+ * 条目是**副本**:GET 接口会往上贴那些没认领到的补丁,不能回头污染重放缓存。
697
+ * 序列号 `seq` 和帧里那个是同一个(客户端据此认出「我漏了几帧」)。
698
+ */
699
+ trajectoryOf(sessionId) {
700
+ const buf = sessionId ? state.trajectoryBySession[sessionId] : null
701
+ const replay = sessionId && replaySource ? replaySource(sessionId) : null
702
+ const merged = mergeTrajectory(
703
+ buf ? liveTrajectoryTurns(buf) : [],
704
+ replay?.trajectory ?? null,
705
+ )
706
+ if (buf?.patches.size) {
707
+ for (const group of merged) {
708
+ for (const entry of group.entries ?? []) {
709
+ const patch = buf.patches.get(entry.id)
710
+ if (patch) applyTrajectoryPatch(entry, patch)
711
+ }
712
+ }
713
+ }
714
+ const capped = capTrajectory(merged, TRAJECTORY_MAX_ENTRIES)
715
+ const cut = capped.dropped > 0
716
+ || (buf?.dropped ?? 0) > 0
717
+ || (replay?.trajectoryDropped ?? 0) > 0
718
+ return {
719
+ sessionId: sessionId ?? null,
720
+ seq: buf?.seq ?? 0,
721
+ turns: capped.turns,
722
+ truncated: cut,
723
+ note: cut ? `轨迹只显示最近一段(读取上限:${TRAJECTORY_MAX_ENTRIES} 条)` : null,
724
+ // 读会话记录是后台动作:手机切到「完整」模式那一刻可能还没读完。
725
+ // 空了要说「正在读」,不能让人以为这个会话没有过程。
726
+ loading: replay?.pending === true && !buf,
727
+ }
728
+ },
729
+
463
730
  snapshot() {
464
731
  const target = targetSessionId()
465
732
  // 内存里记着的那一份(插件在场时看见的)
@@ -529,6 +796,9 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
529
796
  * 每次现读,不缓存:见 setQueueSource 的说明。
530
797
  */
531
798
  queued: target ? queueSource(target) : [],
799
+ subagentsRunning: Boolean(
800
+ target && Object.keys(state.subagentRunningBySession[target] ?? {}).length,
801
+ ),
532
802
  // 同毫秒时用 seq 兜底,顺序才是确定的
533
803
  sessions: [...sessions.values()].sort(
534
804
  (a, b) => (b.lastActivity - a.lastActivity) || (b.seq - a.seq),
@@ -0,0 +1,63 @@
1
+ /** 子代理结构清单缓存:只存 id、父级、类型和名称,不存运行记录或正文。 */
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+
5
+ const TTL_MS = 7 * 24 * 60 * 60 * 1000
6
+
7
+ export function createSubagentCache({ file, ttl = TTL_MS, debounceMs = 500, now = Date.now }) {
8
+ const rows = new Map()
9
+ try {
10
+ const raw = JSON.parse(readFileSync(file, 'utf8'))
11
+ const at = now()
12
+ for (const [parentId, row] of Object.entries(raw ?? {})) {
13
+ if (!row || typeof row.at !== 'number' || at - row.at > ttl || !Array.isArray(row.subagents)) continue
14
+ rows.set(parentId, { subagents: row.subagents, at: row.at })
15
+ }
16
+ } catch {
17
+ // 首次启动或缓存损坏时从空缓存开始。
18
+ }
19
+
20
+ let timer = null
21
+ let dirty = false
22
+ function save() {
23
+ timer = null
24
+ if (!dirty) return
25
+ dirty = false
26
+ try {
27
+ mkdirSync(dirname(file), { recursive: true })
28
+ const tmp = `${file}.tmp`
29
+ const raw = {}
30
+ for (const [parentId, row] of rows) raw[parentId] = row
31
+ writeFileSync(tmp, JSON.stringify(raw))
32
+ renameSync(tmp, file)
33
+ } catch {
34
+ // 缓存写失败不影响当前运行。
35
+ }
36
+ }
37
+ function schedule() {
38
+ dirty = true
39
+ if (timer) return
40
+ timer = setTimeout(save, debounceMs)
41
+ timer.unref?.()
42
+ }
43
+
44
+ return {
45
+ get(parentId) {
46
+ const row = rows.get(parentId)
47
+ if (!row) return null
48
+ if (now() - row.at > ttl) {
49
+ rows.delete(parentId)
50
+ return null
51
+ }
52
+ return row.subagents.map((item) => ({ ...item }))
53
+ },
54
+ set(parentId, subagents) {
55
+ if (!parentId || !Array.isArray(subagents)) return
56
+ rows.set(parentId, { subagents: subagents.map((item) => ({ ...item })), at: now() })
57
+ schedule()
58
+ },
59
+ delete(parentId) {
60
+ if (rows.delete(parentId)) schedule()
61
+ },
62
+ }
63
+ }