opencode-subagent-magazine 1.3.0 → 1.4.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/index.tsx CHANGED
@@ -81,6 +81,19 @@ const I18N: Record<Lang, Record<string, string>> = {
81
81
  "order.asc": "升序(最早在前)",
82
82
  "scroll.wheel": "滚轮翻页",
83
83
  "scroll.click": "点击翻页",
84
+ "ttl.label": "清理周期",
85
+ "ttl.3d": "3 天",
86
+ "ttl.7d": "7 天",
87
+ "ttl.14d": "14 天",
88
+ "ttl.30d": "30 天",
89
+ "ttl.unlimited": "无期限",
90
+ "ttl.toast": "清理周期已设为 {n} 天",
91
+ "ttl.toast_unlimited": "清理周期已设为无期限",
92
+ "clear.title": "确认清除",
93
+ "clear.prompt": "确定清除当前会话所有子代理记录?此操作不可撤销。",
94
+ "clear.prompt_running": "当前有 {n} 个运行中的子代理,清除后将不可恢复。确定继续?",
95
+ "clear.done": "已清除 {n} 条子代理记录",
96
+ "clear.empty": "当前会话无子代理记录",
84
97
  },
85
98
  en: {
86
99
  "panel.title": "SubAgent",
@@ -107,6 +120,19 @@ const I18N: Record<Lang, Record<string, string>> = {
107
120
  "order.asc": "Asc (oldest first)",
108
121
  "scroll.wheel": "Wheel Scroll",
109
122
  "scroll.click": "Click Scroll",
123
+ "ttl.label": "TTL (Time to Live)",
124
+ "ttl.3d": "3 days",
125
+ "ttl.7d": "7 days",
126
+ "ttl.14d": "14 days",
127
+ "ttl.30d": "30 days",
128
+ "ttl.unlimited": "Never",
129
+ "ttl.toast": "TTL set to {n} days",
130
+ "ttl.toast_unlimited": "TTL set to Never",
131
+ "clear.title": "Confirm",
132
+ "clear.prompt": "Clear all sub-agent records for this session? This cannot be undone.",
133
+ "clear.prompt_running": "{n} sub-agent(s) are still running. Clearing will discard them permanently. Continue?",
134
+ "clear.done": "Cleared {n} sub-agent record(s)",
135
+ "clear.empty": "No sub-agent records in this session",
110
136
  },
111
137
  }
112
138
 
@@ -266,6 +292,9 @@ function safeErrorMsg(err: unknown): string {
266
292
  // 模块级缓存:各 session 的 entry 状态独立存储,不随当前视图切换而清除。
267
293
  const globalEntryCache = new Map<string, Map<string, SubEntry>>()
268
294
 
295
+ // 模块级刷新信号:外部(如斜杠命令)触发清除后 +1,组件 scan 依赖它以重扫。
296
+ const [clearTick, setClearTick] = createSignal(0)
297
+
269
298
  function SubAgentPanel(props: {
270
299
  theme: TuiThemeCurrent
271
300
  api: TuiPluginApi
@@ -279,13 +308,24 @@ function SubAgentPanel(props: {
279
308
 
280
309
  // ── session data (single-key, true deletion on cleanup) ──
281
310
  const SESSION_DATA_KEY = `${KV_PREFIX}.session_data`
282
- const TTL_MS = 3 * 24 * 60 * 60 * 1000
311
+ const ttlDaysRaw = parseInt(String(props.api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10)
312
+ const ttlDays = Number.isNaN(ttlDaysRaw) ? 3 : ttlDaysRaw
313
+ const TTL_MS = ttlDays * 24 * 60 * 60 * 1000
314
+
315
+ interface ChildRecord {
316
+ scroll: number
317
+ expanded: string
318
+ entries: SubEntry[]
319
+ clearedIds?: string[]
320
+ }
283
321
 
284
322
  interface SessionRecord {
285
323
  ts: number
286
324
  entries: SubEntry[]
287
325
  scroll: number
288
326
  expanded: string
327
+ children: Record<string, ChildRecord>
328
+ clearedIds?: string[]
289
329
  }
290
330
 
291
331
  const loadSessionData = (): Record<string, SessionRecord> => {
@@ -299,12 +339,27 @@ function SubAgentPanel(props: {
299
339
  try { props.api.kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {}
300
340
  }
301
341
 
342
+ /** 将任意 session ID 解析为父会话 ID + 是否子会话。
343
+ * 通过 SDK session.get(sid).parentID 判断,无 parentID 即为主会话。 */
344
+ const resolveParent = (sid: string): { parentSid: string; isChild: boolean } => {
345
+ try {
346
+ const session = props.api.state.session.get(sid)
347
+ const parentID = (session as any)?.parentID as string | undefined
348
+ if (parentID) return { parentSid: parentID, isChild: true }
349
+ } catch {}
350
+ return { parentSid: sid, isChild: false }
351
+ }
352
+
302
353
  const loadEntries = (sid: string): Map<string, SubEntry> => {
303
354
  const m = new Map<string, SubEntry>()
304
355
  try {
305
- const rec = loadSessionData()[sid]
306
- if (rec?.entries) {
307
- for (const e of rec.entries) m.set(e.id, e)
356
+ const { parentSid, isChild } = resolveParent(sid)
357
+ const rec = loadSessionData()[parentSid]
358
+ if (rec) {
359
+ const source = isChild ? rec.children?.[sid]?.entries : rec.entries
360
+ if (source) {
361
+ for (const e of source) m.set(e.id, e)
362
+ }
308
363
  }
309
364
  } catch {}
310
365
  return m
@@ -316,7 +371,15 @@ function SubAgentPanel(props: {
316
371
  persistTimer = setTimeout(() => {
317
372
  try {
318
373
  const data = loadSessionData()
319
- data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()] }
374
+ const { parentSid, isChild } = resolveParent(sid)
375
+ if (isChild) {
376
+ if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
377
+ if (!data[parentSid].children) data[parentSid].children = {}
378
+ if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
379
+ data[parentSid].children[sid] = { ...data[parentSid].children[sid], entries: [...entries.values()] }
380
+ } else {
381
+ data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()], children: data[sid]?.children ?? {} }
382
+ }
320
383
  saveSessionData(data)
321
384
  } catch {}
322
385
  }, 200)
@@ -325,7 +388,15 @@ function SubAgentPanel(props: {
325
388
  const persistScroll = (sid: string, scroll: number) => {
326
389
  try {
327
390
  const data = loadSessionData()
328
- data[sid] = { ...data[sid], ts: Date.now(), scroll }
391
+ const { parentSid, isChild } = resolveParent(sid)
392
+ if (isChild) {
393
+ if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
394
+ if (!data[parentSid].children) data[parentSid].children = {}
395
+ if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
396
+ data[parentSid].children[sid] = { ...data[parentSid].children[sid], scroll }
397
+ } else {
398
+ data[sid] = { ...data[sid], ts: Date.now(), scroll, children: data[sid]?.children ?? {} }
399
+ }
329
400
  saveSessionData(data)
330
401
  } catch {}
331
402
  }
@@ -333,12 +404,21 @@ function SubAgentPanel(props: {
333
404
  const persistExpanded = (sid: string, expanded: string) => {
334
405
  try {
335
406
  const data = loadSessionData()
336
- data[sid] = { ...data[sid], ts: Date.now(), expanded }
407
+ const { parentSid, isChild } = resolveParent(sid)
408
+ if (isChild) {
409
+ if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
410
+ if (!data[parentSid].children) data[parentSid].children = {}
411
+ if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
412
+ data[parentSid].children[sid] = { ...data[parentSid].children[sid], expanded }
413
+ } else {
414
+ data[sid] = { ...data[sid], ts: Date.now(), expanded, children: data[sid]?.children ?? {} }
415
+ }
337
416
  saveSessionData(data)
338
417
  } catch {}
339
418
  }
340
419
 
341
420
  const cleanupOldSessions = () => {
421
+ if (ttlDays <= 0) return // 无期限,跳过清理
342
422
  try {
343
423
  const data = loadSessionData()
344
424
  const cutoff = Date.now() - TTL_MS
@@ -379,7 +459,15 @@ function SubAgentPanel(props: {
379
459
  clearTimeout(persistTimer)
380
460
  try {
381
461
  const data = loadSessionData()
382
- data[props.sessionId] = { ...data[props.sessionId], ts: Date.now(), entries: [...next.values()] }
462
+ const { parentSid, isChild } = resolveParent(props.sessionId)
463
+ if (isChild) {
464
+ if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
465
+ if (!data[parentSid].children) data[parentSid].children = {}
466
+ if (!data[parentSid].children[props.sessionId]) data[parentSid].children[props.sessionId] = { scroll: 0, expanded: "", entries: [] }
467
+ data[parentSid].children[props.sessionId] = { ...data[parentSid].children[props.sessionId], entries: [...next.values()] }
468
+ } else {
469
+ data[props.sessionId] = { ...data[props.sessionId], ts: Date.now(), entries: [...next.values()], children: data[props.sessionId]?.children ?? {} }
470
+ }
383
471
  saveSessionData(data)
384
472
  } catch {}
385
473
  } else {
@@ -398,7 +486,14 @@ function SubAgentPanel(props: {
398
486
  (() => { try { return props.api.kv.get(`${KV_PREFIX}.open`, true) as boolean } catch { return true } })()
399
487
  )
400
488
  const [expanded, setExpanded] = createSignal<string | undefined>(
401
- (() => { try { return loadSessionData()[props.sessionId]?.expanded || undefined } catch { return undefined } })()
489
+ (() => {
490
+ try {
491
+ const { parentSid, isChild } = resolveParent(props.sessionId)
492
+ const rec = loadSessionData()[parentSid]
493
+ if (rec) return isChild ? rec.children?.[props.sessionId]?.expanded || undefined : rec.expanded || undefined
494
+ } catch {}
495
+ return undefined
496
+ })(),
402
497
  )
403
498
  const [hoveredOpen, setHoveredOpen] = createSignal<string | undefined>(undefined)
404
499
  const [hoveredDismiss, setHoveredDismiss] = createSignal<string | undefined>(undefined)
@@ -406,7 +501,13 @@ function SubAgentPanel(props: {
406
501
  const [hoveredMoreAbove, setHoveredMoreAbove] = createSignal(false)
407
502
  const [hoveredMoreBelow, setHoveredMoreBelow] = createSignal(false)
408
503
  const [scrollOffset, setScrollOffset] = createSignal(
409
- (() => { try { return loadSessionData()[props.sessionId]?.scroll ?? 0 } catch { return 0 } })()
504
+ (() => {
505
+ try {
506
+ const { parentSid, isChild } = resolveParent(props.sessionId)
507
+ const rec = loadSessionData()[parentSid]
508
+ return isChild ? rec?.children?.[props.sessionId]?.scroll ?? 0 : rec?.scroll ?? 0
509
+ } catch { return 0 }
510
+ })(),
410
511
  )
411
512
  const [now, setNow] = createSignal(Date.now())
412
513
  const [renderTick, setRenderTick] = createSignal(0)
@@ -883,23 +984,40 @@ function SubAgentPanel(props: {
883
984
  // On session change: load from kv (entries survive component unmount), then scan+merge.
884
985
  // On same session: only scan+merge (keep event‑driven running entries).
885
986
  let lastSid = props.sessionId
987
+ let lastTick = 0
886
988
  createEffect(() => {
887
989
  const sid = props.sessionId
888
990
  const switched = sid !== lastSid
889
991
  lastSid = sid
992
+ const tick = clearTick() // 外部触发清除时 +1,effect 重跑
993
+ const forceReload = tick !== lastTick && !switched
994
+ lastTick = tick
890
995
  const t = setTimeout(() => {
891
996
  untrack(() => {
892
997
  if (switched) {
893
- const saved = loadSessionData()[sid]?.scroll ?? 0
998
+ const { parentSid, isChild } = resolveParent(sid)
999
+ const data = loadSessionData()
1000
+ const saved = isChild
1001
+ ? data[parentSid]?.children?.[sid]?.scroll ?? 0
1002
+ : data[sid]?.scroll ?? 0
894
1003
  setScrollOffset(saved)
1004
+ // 刷新父会话的访问时间 TTL,防止活跃会话的数据过期
1005
+ if (!isChild && data[sid]?.entries?.length) {
1006
+ data[sid].ts = Date.now()
1007
+ saveSessionData(data)
1008
+ }
895
1009
  }
896
1010
  // scan uses setEntryMapRaw — ephemeral data, not persisted to kv.
897
1011
  // Only event-driven changes (handlePartUpdated, handleSessionEnd) persist.
898
1012
  setEntryMapRaw((prev) => {
899
1013
  // 优先从模块级缓存加载,KV 仅作缓存未命中时的回退
900
- const next = switched
1014
+ const next = (switched || forceReload)
901
1015
  ? new Map(globalEntryCache.get(sid) ?? loadEntries(sid))
902
1016
  : new Map(prev)
1017
+ // 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目
1018
+ const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid)
1019
+ const scanRec = loadSessionData()[scanPSid]
1020
+ const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds)
903
1021
  try {
904
1022
  const msgs = props.api.state.session.messages(sid)
905
1023
  if (msgs && (msgs as any[]).length) {
@@ -920,6 +1038,9 @@ function SubAgentPanel(props: {
920
1038
  const rawStatus = String(st?.status ?? "")
921
1039
  const exists = next.get(id)
922
1040
 
1041
+ // 已手动清除的条目:scan 发现但不在内存 → 跳过重建
1042
+ if (!exists && clearedIds.has(id)) continue
1043
+
923
1044
  // Only create entries for tool calls that entered execution.
924
1045
  // "pending" / empty: skip new entries; allow heuristics for existing ones below.
925
1046
  if ((rawStatus === "pending" || rawStatus === "") && !exists) continue
@@ -1754,7 +1875,8 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1754
1875
  description: "Mark all running sub-agent entries as done (for stuck/zombie entries)",
1755
1876
  slash: { name: "subagent-clear-running" },
1756
1877
  onSelect: (dialog) => {
1757
- const entries = globalEntryCache.get(signals.sessionId)
1878
+ const sid = signals.sessionId
1879
+ const entries = globalEntryCache.get(sid)
1758
1880
  if (!entries || entries.size === 0) {
1759
1881
  const msg = signals.lang() === "zh" ? "暂无子代理条目" : "No sub-agent entries found"
1760
1882
  api.ui.toast({ message: msg })
@@ -1773,11 +1895,21 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1773
1895
  // 立即写 KV
1774
1896
  try {
1775
1897
  const data = JSON.parse(String(api.kv.get(`${KV_PREFIX}.session_data`, "{}")))
1776
- data[signals.sessionId] = {
1777
- ts: Date.now(),
1778
- entries: [...entries.values()],
1779
- scroll: data[signals.sessionId]?.scroll ?? 0,
1780
- expanded: data[signals.sessionId]?.expanded ?? "",
1898
+ const sessionObj = api.state.session.get(sid)
1899
+ const parentID = (sessionObj as any)?.parentID as string | undefined
1900
+ if (parentID) {
1901
+ if (!data[parentID]) data[parentID] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
1902
+ if (!data[parentID].children) data[parentID].children = {}
1903
+ if (!data[parentID].children[sid]) data[parentID].children[sid] = { scroll: 0, expanded: "", entries: [] }
1904
+ data[parentID].children[sid] = { ...data[parentID].children[sid], entries: [...entries.values()] }
1905
+ } else {
1906
+ data[sid] = {
1907
+ ts: Date.now(),
1908
+ entries: [...entries.values()],
1909
+ scroll: data[sid]?.scroll ?? 0,
1910
+ expanded: data[sid]?.expanded ?? "",
1911
+ children: data[sid]?.children ?? {},
1912
+ }
1781
1913
  }
1782
1914
  api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data))
1783
1915
  } catch {}
@@ -1794,6 +1926,96 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1794
1926
  dialog?.clear()
1795
1927
  },
1796
1928
  },
1929
+ {
1930
+ title: "SubAgent Magazine: TTL",
1931
+ value: "subagent-ttl",
1932
+ description: "Set session data retention period (days before auto-cleanup)",
1933
+ slash: { name: "subagent-ttl" },
1934
+ onSelect: (dialog) => {
1935
+ const t = (k: string) => I18N[signals.lang()][k] ?? k
1936
+ const curRaw = parseInt(String(api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10)
1937
+ const curDays = Number.isNaN(curRaw) ? 3 : curRaw
1938
+ const curLabel = curDays === 0 ? t("ttl.unlimited") : `${curDays}d`
1939
+ dialog?.replace(() => (
1940
+ <api.ui.DialogSelect
1941
+ title={`${t("ttl.label")} (${curLabel})`}
1942
+ options={[
1943
+ { title: t("ttl.3d"), value: "3" },
1944
+ { title: t("ttl.7d"), value: "7" },
1945
+ { title: t("ttl.14d"), value: "14" },
1946
+ { title: t("ttl.30d"), value: "30" },
1947
+ { title: t("ttl.unlimited"), value: "0" },
1948
+ ]}
1949
+ onSelect={(opt) => {
1950
+ const days = parseInt(opt.value, 10)
1951
+ api.kv.set(`${KV_PREFIX}.ttl_days`, String(days))
1952
+ const msg = days === 0 ? t("ttl.toast_unlimited") : t("ttl.toast").replace("{n}", String(days))
1953
+ api.ui.toast({ message: msg })
1954
+ dialog?.clear()
1955
+ }}
1956
+ />
1957
+ ))
1958
+ },
1959
+ },
1960
+ {
1961
+ title: "SubAgent Magazine: Clear Entries",
1962
+ value: "subagent-clear-entries",
1963
+ description: "Delete all sub-agent records for the current session (cannot be undone)",
1964
+ slash: { name: "subagent-clear-entries" },
1965
+ onSelect: (dialog) => {
1966
+ const t = (k: string) => I18N[signals.lang()][k] ?? k
1967
+ const sid = signals.sessionId
1968
+ const sessionObj = api.state.session.get(sid)
1969
+ const parentID = (sessionObj as any)?.parentID as string | undefined
1970
+ // 检查是否存在运行中的条目
1971
+ const cached = globalEntryCache.get(sid)
1972
+ let runningCount = 0
1973
+ if (cached) {
1974
+ for (const [, e] of cached) { if (e.status === "running") runningCount++ }
1975
+ }
1976
+ const msg = runningCount > 0
1977
+ ? t("clear.prompt_running").replace("{n}", String(runningCount))
1978
+ : t("clear.prompt")
1979
+ dialog?.replace(() => (
1980
+ <api.ui.DialogConfirm
1981
+ title={t("clear.title")}
1982
+ message={msg}
1983
+ onConfirm={() => {
1984
+ try {
1985
+ const data = JSON.parse(String(api.kv.get(`${KV_PREFIX}.session_data`, "{}")))
1986
+ let count = 0
1987
+ if (parentID) {
1988
+ if (data[parentID]?.children?.[sid]) {
1989
+ const child = data[parentID].children[sid]
1990
+ const ids = child.entries?.map((e: any) => e.id) ?? []
1991
+ count = ids.length
1992
+ child.entries = []
1993
+ child.scroll = 0
1994
+ child.expanded = ""
1995
+ child.clearedIds = [...new Set([...(child.clearedIds ?? []), ...ids])]
1996
+ }
1997
+ } else {
1998
+ count = data[sid]?.entries?.length ?? 0
1999
+ if (data[sid]) {
2000
+ const ids = data[sid].entries?.map((e: any) => e.id) ?? []
2001
+ data[sid].entries = []
2002
+ data[sid].scroll = 0
2003
+ data[sid].expanded = ""
2004
+ data[sid].clearedIds = [...new Set([...(data[sid].clearedIds ?? []), ...ids])]
2005
+ }
2006
+ }
2007
+ api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data))
2008
+ globalEntryCache.delete(sid)
2009
+ setClearTick((v) => v + 1)
2010
+ const msg = t("clear.done").replace("{n}", String(count))
2011
+ api.ui.toast({ message: msg })
2012
+ } catch {}
2013
+ dialog?.clear()
2014
+ }}
2015
+ />
2016
+ ))
2017
+ },
2018
+ },
1797
2019
  ])
1798
2020
  }
1799
2021