opencode-subagent-magazine 1.6.1 → 1.7.1

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.
@@ -14,15 +14,15 @@ import {
14
14
  import { PLUGIN_VERSION } from "../_version"
15
15
  import { copyText } from "../clipboard"
16
16
  import { createT } from "../i18n"
17
- import type { Lang, SortOrder, ScrollMode, SubEntry, SubStatus, SessionRecord } from "../core/types"
18
- import { SUBAGENT_TOOLS } from "../core/types"
19
- import { visualWidth, truncate, fmtDurationShort, fmtTokens, safeErrorMsg } from "../core/format"
17
+ import type { Lang, SortOrder, ScrollMode, SubEntry, SubStatus, SessionRecord, TimeFormat } from "../core/types"
18
+ import { SUBAGENT_TOOLS, isBackgroundInput } from "../core/types"
19
+ import { visualWidth, truncate, fmtDuration, fmtTokens, safeErrorMsg } from "../core/format"
20
20
  import { rgb, desaturateTo, dimColor, FALLBACK, MAX_SAT } from "../core/color"
21
- import { KV_PREFIX } from "../core/kv"
21
+ import { KV_PREFIX, updateSessionData } from "../core/kv"
22
22
  import type { PanelApi, PanelEvent } from "./panel-api"
23
23
  import { globalEntryCache, clearTick } from "./store"
24
24
  import { isDirectChildSession } from "./session-routing"
25
- import { findSubEntryKey, upsertSubEntry } from "./entry-map"
25
+ import { findSubEntryKey, mergeSubEntries, upsertSubEntry } from "./entry-map"
26
26
 
27
27
  /** Entry line left prefix: icon + space + status dot + space */
28
28
  const LEFT_PAD = 4
@@ -32,6 +32,25 @@ const INDENT = 2
32
32
  // Sidebar component
33
33
  // ===================================================================
34
34
 
35
+ /** 读取 part/state 两级的 tool metadata(TUI 数据层放在 part 级,旧形状嵌在 state 里)。 */
36
+ function partMetadata(
37
+ part: Record<string, any>,
38
+ st: Record<string, unknown> | undefined,
39
+ ): Record<string, unknown> | undefined {
40
+ const partMeta = part?.metadata as Record<string, unknown> | undefined
41
+ if (partMeta && Object.keys(partMeta).length > 0) return partMeta
42
+ const stateMeta = st?.metadata as Record<string, unknown> | undefined
43
+ return stateMeta && Object.keys(stateMeta).length > 0 ? stateMeta : undefined
44
+ }
45
+
46
+ /** model 字段的有效值——历史 KV 里可能混入 "[object Object]" 这类序列化垃圾。 */
47
+ function validModel(model: string | undefined): model is string {
48
+ if (typeof model !== "string") return false
49
+ const s = model.trim()
50
+ // 历史脏值:String(对象) 与未解析的 JSON 文本(session_v2.model 的形态)。
51
+ return s.length > 0 && s !== "[object Object]" && !s.startsWith("{")
52
+ }
53
+
35
54
  export function SubAgentPanel(props: {
36
55
  api: PanelApi
37
56
  theme: Record<string, unknown>
@@ -40,6 +59,11 @@ export function SubAgentPanel(props: {
40
59
  sortOrder: () => SortOrder
41
60
  scrollMode: () => ScrollMode
42
61
  borderVisible: () => boolean
62
+ showEntryCost: () => boolean
63
+ showEntryTime: () => boolean
64
+ showEntryTokens: () => boolean
65
+ timeFormat: () => TimeFormat
66
+ showOrigin: () => boolean
43
67
  sessionId: string
44
68
  }): JSX.Element {
45
69
  const t = createT(() => props.lang())
@@ -73,8 +97,34 @@ export function SubAgentPanel(props: {
73
97
  } catch { return {} }
74
98
  }
75
99
 
76
- const saveSessionData = (data: Record<string, SessionRecord>) => {
77
- try { props.api.kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {}
100
+ /**
101
+ * 持久化存储的原子 read-merge-write:所有写入都经过这里。
102
+ * 多个 TUI 共用一个 KV 文件时,直接写入内存快照
103
+ * 会丢掉其他实例创建的条目。
104
+ */
105
+ const mutateSessionData = (mutator: (data: Record<string, SessionRecord>) => void) => {
106
+ // 宿主存储可能异步 reject;吞掉以免失败写入变成未处理 rejection 拖垮 TUI。
107
+ void Promise.resolve(updateSessionData(props.api.kv, mutator)).catch(() => {})
108
+ }
109
+
110
+ /** 带合理默认值的 SessionRecord(记录可能在首次写入时创建)。 */
111
+ const ensureRootRecord = (data: Record<string, SessionRecord>, sid: string): SessionRecord => {
112
+ const rec = data[sid]
113
+ if (rec) return rec
114
+ const created: SessionRecord = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
115
+ data[sid] = created
116
+ return created
117
+ }
118
+
119
+ const ensureChildRecord = (
120
+ data: Record<string, SessionRecord>,
121
+ parentSid: string,
122
+ sid: string,
123
+ ): ChildRecord => {
124
+ const parent = ensureRootRecord(data, parentSid)
125
+ if (!parent.children) parent.children = {}
126
+ if (!parent.children[sid]) parent.children[sid] = { scroll: 0, expanded: "", entries: [] }
127
+ return parent.children[sid]
78
128
  }
79
129
 
80
130
  /** 将任意 session ID 解析为父会话 ID + 是否子会话。
@@ -110,66 +160,62 @@ export function SubAgentPanel(props: {
110
160
  clearTimeout(persistTimer)
111
161
  persistTimer = setTimeout(() => {
112
162
  try {
113
- const data = loadSessionData()
114
- const { parentSid, isChild } = resolveParent(sid)
115
- if (isChild) {
116
- if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
117
- if (!data[parentSid].children) data[parentSid].children = {}
118
- if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
119
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], entries: [...entries.values()] }
120
- } else {
121
- data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()], children: data[sid]?.children ?? {} }
122
- }
123
- saveSessionData(data)
163
+ mutateSessionData((data) => {
164
+ const { parentSid, isChild } = resolveParent(sid)
165
+ if (isChild) {
166
+ const parent = ensureRootRecord(data, parentSid)
167
+ const child = ensureChildRecord(data, parentSid, sid)
168
+ child.entries = [...mergeSubEntries(child.entries ?? [], entries.values()).values()]
169
+ parent.ts = Date.now()
170
+ } else {
171
+ const rec = ensureRootRecord(data, sid)
172
+ rec.ts = Date.now()
173
+ rec.entries = [...mergeSubEntries(rec.entries ?? [], entries.values()).values()]
174
+ }
175
+ })
124
176
  } catch {}
125
177
  }, 200)
126
178
  }
127
179
 
128
180
  const persistScroll = (sid: string, scroll: number) => {
129
181
  try {
130
- const data = loadSessionData()
131
- const { parentSid, isChild } = resolveParent(sid)
132
- if (isChild) {
133
- if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
134
- if (!data[parentSid].children) data[parentSid].children = {}
135
- if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
136
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], scroll }
137
- } else {
138
- data[sid] = { ...data[sid], ts: Date.now(), scroll, children: data[sid]?.children ?? {} }
139
- }
140
- saveSessionData(data)
182
+ mutateSessionData((data) => {
183
+ const { parentSid, isChild } = resolveParent(sid)
184
+ if (isChild) {
185
+ ensureChildRecord(data, parentSid, sid).scroll = scroll
186
+ } else {
187
+ const rec = ensureRootRecord(data, sid)
188
+ rec.ts = Date.now()
189
+ rec.scroll = scroll
190
+ }
191
+ })
141
192
  } catch {}
142
193
  }
143
194
 
144
195
  const persistExpanded = (sid: string, expanded: string) => {
145
196
  try {
146
- const data = loadSessionData()
147
- const { parentSid, isChild } = resolveParent(sid)
148
- if (isChild) {
149
- if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
150
- if (!data[parentSid].children) data[parentSid].children = {}
151
- if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
152
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], expanded }
153
- } else {
154
- data[sid] = { ...data[sid], ts: Date.now(), expanded, children: data[sid]?.children ?? {} }
155
- }
156
- saveSessionData(data)
197
+ mutateSessionData((data) => {
198
+ const { parentSid, isChild } = resolveParent(sid)
199
+ if (isChild) {
200
+ ensureChildRecord(data, parentSid, sid).expanded = expanded
201
+ } else {
202
+ const rec = ensureRootRecord(data, sid)
203
+ rec.ts = Date.now()
204
+ rec.expanded = expanded
205
+ }
206
+ })
157
207
  } catch {}
158
208
  }
159
209
 
160
210
  const cleanupOldSessions = () => {
161
211
  if (ttlDays <= 0) return // 无期限,跳过清理
162
212
  try {
163
- const data = loadSessionData()
164
213
  const cutoff = Date.now() - TTL_MS
165
- let changed = false
166
- for (const sid of Object.keys(data)) {
167
- if (data[sid].ts < cutoff) {
168
- delete data[sid]
169
- changed = true
214
+ mutateSessionData((data) => {
215
+ for (const sid of Object.keys(data)) {
216
+ if (data[sid].ts < cutoff) delete data[sid]
170
217
  }
171
- }
172
- if (changed) saveSessionData(data)
218
+ })
173
219
  } catch {}
174
220
  }
175
221
 
@@ -198,17 +244,19 @@ export function SubAgentPanel(props: {
198
244
  if (needsImmediateFlush) {
199
245
  clearTimeout(persistTimer)
200
246
  try {
201
- const data = loadSessionData()
202
- const { parentSid, isChild } = resolveParent(props.sessionId)
203
- if (isChild) {
204
- if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
205
- if (!data[parentSid].children) data[parentSid].children = {}
206
- if (!data[parentSid].children[props.sessionId]) data[parentSid].children[props.sessionId] = { scroll: 0, expanded: "", entries: [] }
207
- data[parentSid].children[props.sessionId] = { ...data[parentSid].children[props.sessionId], entries: [...next.values()] }
208
- } else {
209
- data[props.sessionId] = { ...data[props.sessionId], ts: Date.now(), entries: [...next.values()], children: data[props.sessionId]?.children ?? {} }
210
- }
211
- saveSessionData(data)
247
+ mutateSessionData((data) => {
248
+ const { parentSid, isChild } = resolveParent(props.sessionId)
249
+ if (isChild) {
250
+ const parent = ensureRootRecord(data, parentSid)
251
+ const child = ensureChildRecord(data, parentSid, props.sessionId)
252
+ child.entries = [...mergeSubEntries(child.entries ?? [], next.values()).values()]
253
+ parent.ts = Date.now()
254
+ } else {
255
+ const rec = ensureRootRecord(data, props.sessionId)
256
+ rec.ts = Date.now()
257
+ rec.entries = [...mergeSubEntries(rec.entries ?? [], next.values()).values()]
258
+ }
259
+ })
212
260
  } catch {}
213
261
  } else {
214
262
  persistEntries(props.sessionId, next)
@@ -355,7 +403,9 @@ export function SubAgentPanel(props: {
355
403
  // V2 事件流是全局的——若 payload 带发起会话 ID,则只归账到正在查看的会话,
356
404
  // 避免其他会话的子代理写进当前侧边栏;V1 宿主已按会话 scope,不传 sessionID。
357
405
  const eventSid = event.payload?.sessionID !== undefined ? String(event.payload.sessionID) : undefined
358
- if (event.scope === "global" && eventSid !== props.sessionId) return
406
+ if (event.scope === "global" && eventSid !== props.sessionId) {
407
+ return
408
+ }
359
409
 
360
410
  // SubtaskPart
361
411
  if (part.type === "subtask") {
@@ -368,7 +418,7 @@ export function SubAgentPanel(props: {
368
418
  const subSid = part.sessionID !== undefined ? String(part.sessionID) : undefined
369
419
  const partModel = part.model as { modelID?: string } | undefined
370
420
  const modelId = partModel?.modelID ? String(partModel.modelID) : undefined
371
- upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId })
421
+ upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId, origin: "sub" })
372
422
  }
373
423
 
374
424
  // ToolPart
@@ -388,7 +438,7 @@ export function SubAgentPanel(props: {
388
438
  if (rawStatus === "error") {
389
439
  const id = `tool:${String(part.id ?? "")}`
390
440
  if (!part.id) return
391
- const stMeta = st?.metadata as Record<string, unknown> | undefined
441
+ const stMeta = partMetadata(part as Record<string, any>, st)
392
442
  const errorSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
393
443
  : stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
394
444
  : undefined
@@ -402,6 +452,8 @@ export function SubAgentPanel(props: {
402
452
  prompt: existing.prompt,
403
453
  sessionId: existing.sessionId ?? errorSid,
404
454
  status: "error",
455
+ origin: "tool",
456
+ background: isBackgroundInput(st?.input),
405
457
  })
406
458
  }
407
459
  return
@@ -414,8 +466,8 @@ export function SubAgentPanel(props: {
414
466
  // Background tasks: tool completion ≠ agent completion — keep running until session.idle
415
467
  // Only keep running if state metadata confirms a child session was spawned;
416
468
  // otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever.
417
- if ((input?.run_in_background === true || input?.background === true) && status === "done") {
418
- const stMetaCheck = st?.metadata as Record<string, unknown> | undefined
469
+ if (isBackgroundInput(input) && status === "done") {
470
+ const stMetaCheck = partMetadata(part as Record<string, any>, st)
419
471
  const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined
420
472
  if (hasChild) status = "running"
421
473
  }
@@ -428,11 +480,11 @@ export function SubAgentPanel(props: {
428
480
  const id = `tool:${String(part.id ?? crypto.randomUUID())}`
429
481
  // Child session ID lives in state-level metadata (ToolStateCompleted.metadata),
430
482
  // injected by the tool executor. ToolPart.sessionID is the parent session.
431
- const stMeta = st?.metadata as Record<string, unknown> | undefined
483
+ const stMeta = partMetadata(part as Record<string, any>, st)
432
484
  const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
433
485
  : stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
434
486
  : undefined
435
- upsertEntry({ id, title, agent, prompt, sessionId: subSid, status })
487
+ upsertEntry({ id, title, agent, prompt, sessionId: subSid, status, origin: "tool", background: isBackgroundInput(input) })
436
488
  }
437
489
  }
438
490
 
@@ -620,23 +672,35 @@ export function SubAgentPanel(props: {
620
672
  if (!found) {
621
673
  const data = loadSessionData()
622
674
  const rec = data[parentSid]
623
- if (rec?.entries) {
675
+ if (rec?.entries?.length) {
624
676
  const fallbackMap = new Map(rec.entries.map((e: SubEntry) => [e.id, e]))
625
677
  found = tryMatchAndUpdate(fallbackMap, sid, status, nowTs)
626
678
  if (found) {
627
- // 回退命中后写入 KV 并回填缓存
628
- data[parentSid] = { ...rec, ts: nowTs, entries: [...fallbackMap.values()] }
629
- saveSessionData(data)
679
+ // 回退命中后写入 KV 并回填缓存。
680
+ // merge(而非整表替换):其他实例写入的新条目不能被旧缓存覆盖。
630
681
  globalEntryCache.set(parentSid, fallbackMap)
682
+ mutateSessionData((latest) => {
683
+ const latestRec = latest[parentSid]
684
+ latest[parentSid] = {
685
+ ...(latestRec ?? rec),
686
+ ts: nowTs,
687
+ entries: [...mergeSubEntries(latestRec?.entries ?? [], fallbackMap.values()).values()],
688
+ }
689
+ })
631
690
  }
632
691
  }
633
692
  }
634
693
 
635
- // 将模块级缓存中的最新状态同步到 KV
694
+ // 将模块级缓存中的最新状态 merge 进 KV(缓存可能缺少其他实例的新条目)
636
695
  if (found && parentCache) {
637
- const data = loadSessionData()
638
- data[parentSid] = { ...data[parentSid], ts: nowTs, entries: [...parentCache.values()] }
639
- saveSessionData(data)
696
+ mutateSessionData((data) => {
697
+ const rec = data[parentSid]
698
+ data[parentSid] = {
699
+ ...(rec ?? { entries: [], scroll: 0, expanded: "", children: {}, ts: nowTs }),
700
+ ts: nowTs,
701
+ entries: [...mergeSubEntries(rec?.entries ?? [], parentCache.values()).values()],
702
+ }
703
+ })
640
704
  }
641
705
  }
642
706
  } catch {}
@@ -674,41 +738,449 @@ export function SubAgentPanel(props: {
674
738
  // ── bumpRenderTick: force re-render (visual-cache pattern) ──
675
739
  const bump = () => setRenderTick((v) => v + 1)
676
740
 
741
+ // ── history scan(mount/switch、重试与消息驱动 rescan 共用)──
742
+
743
+ /** 将子会话已 idle 的 running 条目标记为已落定。
744
+ * 纯 map 变换——是否持久化由调用方决定。 */
745
+ const settleIdleEntries = (prev: Map<string, SubEntry>): Map<string, SubEntry> => {
746
+ let changed = false
747
+ const next = new Map(prev)
748
+ for (const [id, entry] of next) {
749
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
750
+ // 无链接条目:先用宿主/本地库按 call id 回填子会话 id;
751
+ // 回填不了就维持原状(不猜测、不设时间阈值)。
752
+ let sid = entry.sessionId
753
+ if (!sid) {
754
+ try {
755
+ const callId = String(entry.id).replace(/^tool:/, "")
756
+ sid = props.api.session.resolveChild?.({
757
+ parentId: props.sessionId, callId, agent: entry.agent, startedAt: entry.startedAt,
758
+ })
759
+ } catch {}
760
+ if (!sid) continue
761
+ next.set(id, { ...entry, sessionId: sid })
762
+ changed = true
763
+ }
764
+ try {
765
+ const st = props.api.session.status(sid)
766
+ if (!st || st.type !== "idle") continue
767
+ const tokens = props.api.usage.readSessionTokens(sid)
768
+ const cost = props.api.usage.readSessionCost(sid)
769
+ const finalStatus: SubStatus = entry.status === "cancel_requested" && entry.abortAccepted
770
+ ? "cancelled"
771
+ : "done"
772
+ next.set(id, {
773
+ ...entry, sessionId: sid, status: finalStatus, endedAt: Date.now(),
774
+ tokens: tokens ?? entry.tokens,
775
+ cost: cost ?? entry.cost,
776
+ })
777
+ changed = true
778
+ } catch {}
779
+ }
780
+ return changed ? next : prev
781
+ }
782
+
783
+ /** 周期性兜底遗漏的 session.idle 事件(僵尸 running 条目)。
784
+ * 由 500ms 定时器驱动,即使没有事件到达也能让条目落定。 */
785
+ const reconcileIdleEntries = () => {
786
+ try {
787
+ const before = entryMap()
788
+ const after = settleIdleEntries(before)
789
+ if (after !== before) setEntryMap(after)
790
+ } catch {}
791
+ }
792
+
793
+ /** 扫描会话历史并 merge 进 entry map。
794
+ * 消息数据尚不可用时返回 false,供调用方重试。 */
795
+ const runSessionScan = (sid: string, replace: boolean): boolean => {
796
+ let msgs: unknown[] | undefined
797
+ try { msgs = props.api.session.messages(sid) } catch {}
798
+ if (!msgs || (msgs as any[]).length === 0) return false
799
+ // scan 结果先写入内存(setEntryMapRaw);scan 末尾统一走 persistEntries 的 merge-safe 落盘。
800
+ setEntryMapRaw((prev) => {
801
+ // 优先从模块级缓存加载,KV 仅作缓存未命中时的回退
802
+ const next = replace
803
+ ? new Map(globalEntryCache.get(sid) ?? loadEntries(sid))
804
+ : new Map(prev)
805
+ // 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目
806
+ const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid)
807
+ const scanRec = loadSessionData()[scanPSid]
808
+ const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds)
809
+ try {
810
+ for (const msg of msgs as any[]) {
811
+ const parts = props.api.session.part((msg as any).id) ?? []
812
+ for (const partRaw of parts) {
813
+ const part = partRaw as Record<string, unknown>
814
+
815
+ // Subtask entries are purely event-driven — never created by scan.
816
+ // (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
817
+ if (part.type === "tool") {
818
+ const tool = String((part as any).tool ?? "")
819
+ if (!SUBAGENT_TOOLS.has(tool)) continue
820
+ const id = `tool:${String(part.id ?? "")}`
821
+ if (!part.id) continue
822
+
823
+ const st = (part as any).state as Record<string, unknown> | undefined
824
+ const rawStatus = String(st?.status ?? "")
825
+ const scanInput = st?.input as Record<string, unknown> | undefined
826
+ // TUI 数据层把子代理 metadata 放在 part 级;旧缓存
827
+ // 形状可能嵌在 state 里。两处都读。
828
+ const scanMeta = partMetadata(part, st)
829
+ let scanSubSid = scanMeta?.session_id !== undefined ? String(scanMeta.session_id)
830
+ : scanMeta?.sessionId !== undefined ? String(scanMeta.sessionId)
831
+ : scanMeta?.sessionID !== undefined ? String(scanMeta.sessionID)
832
+ : undefined
833
+ // 宿主缓存/历史缺少子会话链接时,用本地库按工具 call id 回填。
834
+ if (!scanSubSid) {
835
+ try {
836
+ scanSubSid = props.api.session.resolveChild?.({ parentId: sid, callId: String(part.id) })
837
+ } catch {}
838
+ }
839
+ const existingKey = findSubEntryKey(next, id, scanSubSid)
840
+ const exists = existingKey ? next.get(existingKey) : undefined
841
+
842
+ // 已手动清除的条目:scan 发现但不在内存 → 跳过重建
843
+ if (!exists && clearedIds.has(id)) continue
844
+
845
+ // Only create entries for tool calls that entered execution.
846
+ // "pending" / empty: skip new entries; allow heuristics for existing ones below.
847
+ if ((rawStatus === "pending" || rawStatus === "") && !exists) continue
848
+
849
+ // "error": only update existing, never create a new entry
850
+ if (rawStatus === "error") {
851
+ if (exists && exists.status === "running") {
852
+ next.set(existingKey ?? id, { ...exists, status: "error", endedAt: Date.now() })
853
+ }
854
+ continue
855
+ }
856
+
857
+ // 已落定的条目:仅在补全缺失数据时回访
858
+ // (session/tokens/cost/model)。其终态必须保持。
859
+ const settled =
860
+ exists !== undefined &&
861
+ exists.status !== "running" &&
862
+ exists.status !== "cancel_requested"
863
+ const needsRepair =
864
+ settled &&
865
+ (exists!.sessionId === undefined ||
866
+ exists!.tokens === undefined ||
867
+ exists!.cost === undefined ||
868
+ !validModel(exists!.model))
869
+ if (settled && !needsRepair) continue
870
+
871
+ let status: SubStatus = "running"
872
+ if (rawStatus === "completed") status = "done"
873
+ // Background tasks: tool completion ≠ agent completion — keep running until session.idle
874
+ // 仅在 metadata 确认已派生子会话时保持 running。
875
+ if (isBackgroundInput(scanInput) && status === "done") {
876
+ if (scanSubSid !== undefined) status = "running"
877
+ }
878
+ // 子会话存活判定:工具调用已完成但子会话仍 busy,
879
+ // 说明子代理本身仍在运行(覆盖缓存 part 中
880
+ // 缺少 background 标志的 input)。
881
+ if (status === "done" && scanSubSid) {
882
+ try {
883
+ const childStatus = props.api.session.status(scanSubSid)
884
+ if (childStatus && childStatus.type !== "idle") status = "running"
885
+ } catch {}
886
+ }
887
+
888
+ if (settled) {
889
+ // 修复流程:保留已记录的终态。
890
+ status = exists!.status
891
+ } else if (exists && status === "running") {
892
+ // Running entry with no explicit status improvement from part:
893
+ // try message-level heuristics first, then time-based fallback.
894
+ if (!rawStatus) {
895
+ const msgTokens = (msg as any)?.tokens as Record<string, unknown> | undefined
896
+ if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
897
+ status = "done" // LLM returned tokens → agent completed
898
+ } else {
899
+ // 无终态证据时不猜测:等本地库/事件回填子会话状态后由 reconcile 落定。
900
+ continue
901
+ }
902
+ } else {
903
+ continue
904
+ }
905
+ }
906
+
907
+ // If already tracked as running but tool state says completed/error → update
908
+ // If not tracked → add fresh
909
+
910
+ const agent = String((part as any).subagent_type ?? scanInput?.agent ?? scanInput?.subagent_type ?? tool)
911
+ const prompt = String(scanInput?.prompt ?? (part as any).description ?? "")
912
+ const desc = scanInput?.description !== undefined ? String(scanInput.description) : ""
913
+ const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40)
914
+
915
+ let tokens: number | undefined
916
+ if (scanSubSid) tokens = props.api.usage.readSessionTokens(scanSubSid)
917
+ let cost: number | undefined
918
+ if (scanSubSid) cost = props.api.usage.readSessionCost(scanSubSid)
919
+ let model: string | undefined
920
+ if (scanSubSid) model = props.api.usage.readSessionModel(scanSubSid)
921
+
922
+ const entryKey = existingKey ?? id
923
+ next.set(entryKey, {
924
+ ...(exists ?? { id }),
925
+ title: title || exists?.title || "",
926
+ agent: exists?.agent ?? agent,
927
+ prompt: prompt || exists?.prompt || "",
928
+ // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
929
+ tokens: exists?.tokens ?? tokens,
930
+ cost: exists?.cost ?? cost,
931
+ model: validModel(exists?.model) ? exists!.model : model,
932
+ sessionId: exists?.sessionId ?? scanSubSid,
933
+ status,
934
+ startedAt: exists?.startedAt || Date.now(),
935
+ endedAt: exists?.endedAt ?? (status === "done" ? Date.now() : undefined),
936
+ origin: "tool",
937
+ background: isBackgroundInput(scanInput) || exists?.background === true,
938
+ })
939
+ if (entryKey !== id) next.delete(id)
940
+ }
941
+ }
942
+ }
943
+ } catch {}
944
+ return next
945
+ })
946
+ // Reconcile: check running entries against live child session status.
947
+ // Covers session.idle events missed while user was inside a child session.
948
+ setEntryMapRaw((prev) => settleIdleEntries(prev))
949
+ // 持久化 scan 修复结果(merge-safe:缺失字段始终以 KV 现有值为准),
950
+ // 让恢复的条目在重新挂载后仍在,并能同步到其他实例。
951
+ const snapshot = entryMap()
952
+ if (snapshot.size > 0) {
953
+ globalEntryCache.set(sid, new Map(snapshot))
954
+ persistEntries(sid, snapshot)
955
+ }
956
+ return true
957
+ }
958
+
959
+ let rescanTimer: ReturnType<typeof setTimeout> | undefined
960
+
961
+ /** 当最新消息与 entry map 不一致时为 true:子代理 tool part 尚未建条目,
962
+ * 或非后台 tool 已完成而条目仍停在 running。
963
+ * 事件驱动 rescan 与周期性 discovery poll 共用的轻量检查:
964
+ * 即使宿主的所有事件订阅都静默失效,
965
+ * 也能恢复条目。 */
966
+ const missingNewestToolPart = (): boolean => {
967
+ try {
968
+ const msgs = props.api.session.messages(props.sessionId) as any[] | undefined
969
+ if (!msgs || msgs.length === 0) return false
970
+ const map = entryMap()
971
+ const from = Math.max(0, msgs.length - 6)
972
+ for (let i = msgs.length - 1; i >= from; i--) {
973
+ const parts = props.api.session.part(msgs[i]?.id) ?? []
974
+ for (const part of parts as any[]) {
975
+ if (!part || part.type !== "tool") continue
976
+ if (!SUBAGENT_TOOLS.has(String(part.tool ?? ""))) continue
977
+ const partId = String(part.id ?? "")
978
+ if (!partId) continue
979
+ const st = part.state as Record<string, unknown> | undefined
980
+ const rawStatus = String(st?.status ?? "")
981
+ // scan 从不为 pending/error part 创建条目;
982
+ // 这里忽略它们可避免对一直保持该状态的 part 反复 rescan。
983
+ if (rawStatus !== "running" && rawStatus !== "completed") continue
984
+ const id = `tool:${partId}`
985
+ const meta = partMetadata(part, st)
986
+ const subSid = meta?.session_id !== undefined ? String(meta.session_id)
987
+ : meta?.sessionId !== undefined ? String(meta.sessionId)
988
+ : undefined
989
+ const key = findSubEntryKey(map, id, subSid)
990
+ const entry = key ? map.get(key) : undefined
991
+ if (!entry) return true
992
+ // 非后台 tool 已完成、条目却仍停在 running:补一次 scan 收尾
993
+ // (success 事件可能因缺少 metadata 被适配层丢弃)。
994
+ const input = st?.input as Record<string, unknown> | undefined
995
+ const isBackground = isBackgroundInput(input)
996
+ const stillRunning = entry.status === "running" || entry.status === "cancel_requested"
997
+ if (rawStatus === "completed" && !isBackground && stillRunning) return true
998
+ }
999
+ }
1000
+ } catch {}
1001
+ return false
1002
+ }
1003
+
1004
+ /** 新消息到达时防抖重扫。覆盖 scan 早于宿主消息缓存
1005
+ * 加载完成的竞态。 */
1006
+ const scheduleRescan = () => {
1007
+ if (disposed) return
1008
+ clearTimeout(rescanTimer)
1009
+ rescanTimer = setTimeout(() => {
1010
+ if (disposed) return
1011
+ untrack(() => {
1012
+ if (!missingNewestToolPart()) return
1013
+ runSessionScan(props.sessionId, false)
1014
+ bump()
1015
+ })
1016
+ }, 400)
1017
+ }
1018
+
1019
+ // usage enrichment 轮询的 round-robin 游标。
1020
+ let enrichCursor = 0
1021
+
677
1022
  onMount(() => {
678
1023
  // Fast clock for smooth time display, separate from token polling
679
1024
  const clock = setInterval(() => { setNow(Date.now()); bump() }, 100)
680
1025
  // Token poll — runs every 500ms for running entries
1026
+ let tick = 0
681
1027
  const tokenTimer = setInterval(() => {
1028
+ tick++
682
1029
  untrack(() => {
1030
+ let enriched: Map<string, SubEntry> | undefined
1031
+ let attempted = 0
683
1032
  setEntryMapRaw((prev) => {
684
1033
  let changed = false
685
1034
  const next = new Map(prev)
1035
+ // 每 ~2s 从本地库同步衍生会话(spawn):没有对应工具条目的
1036
+ // 子会话补成 sub: 条目(标题/agent/用量/终态都来自数据库)。
1037
+ if (tick % 4 === 0) {
1038
+ try {
1039
+ const children = props.api.session.listChildren?.(props.sessionId)
1040
+ if (children) {
1041
+ const linked = new Set<string>()
1042
+ for (const e of next.values()) if (e.sessionId) linked.add(e.sessionId)
1043
+ const { parentSid, isChild } = resolveParent(props.sessionId)
1044
+ const rec = loadSessionData()[parentSid]
1045
+ const cleared = new Set<string>(isChild ? rec?.children?.[props.sessionId]?.clearedIds : rec?.clearedIds)
1046
+ for (const child of children) {
1047
+ if (!child.id || linked.has(child.id)) continue
1048
+ const id = `sub:${child.id}`
1049
+ if (cleared.has(id)) continue
1050
+ const finished = child.timeIdle != null && child.active !== true
1051
+ const status: SubStatus = finished
1052
+ ? (child.idleOutcome === "interrupted" ? "cancelled" : "done")
1053
+ : "running"
1054
+ next.set(id, {
1055
+ id,
1056
+ title: child.title ?? child.agent ?? "",
1057
+ agent: child.agent ?? "?",
1058
+ prompt: "",
1059
+ status,
1060
+ sessionId: child.id,
1061
+ startedAt: child.timeCreated ?? Date.now(),
1062
+ endedAt: finished ? child.timeIdle : undefined,
1063
+ tokens: child.tokens,
1064
+ cost: child.cost,
1065
+ model: child.model,
1066
+ origin: "sub",
1067
+ })
1068
+ changed = true
1069
+ }
1070
+ }
1071
+ } catch {}
1072
+ // 恢复运行:终态条目若子会话被 resume(宿主或本地库回报 busy),
1073
+ // 回到 running,等下一次真实 idle 再落定。
1074
+ for (const [id, entry] of next) {
1075
+ if (entry.status !== "done" && entry.status !== "error") continue
1076
+ const sid = entry.sessionId
1077
+ if (!sid) continue
1078
+ try {
1079
+ if (props.api.session.status(sid)?.type === "busy") {
1080
+ next.set(id, { ...entry, status: "running", endedAt: undefined })
1081
+ changed = true
1082
+ }
1083
+ } catch {}
1084
+ }
1085
+ }
1086
+ // 候选:仍缺 usage 数据的条目。以 round-robin 遍历,
1087
+ // 避免一串读不到数据的会话(如 free-model 子会话)
1088
+ // 饿死列表其余部分;每个 tick 只读取少数会话。
1089
+ const candidates: string[] = []
686
1090
  for (const [id, entry] of next) {
687
- if (entry.status === "running" && entry.sessionId) {
688
- // Only read from child sessions, never the parent
1091
+ const running = entry.status === "running" || entry.status === "cancel_requested"
1092
+ const missing = entry.tokens === undefined || entry.cost === undefined || !validModel(entry.model)
1093
+ // 无链接条目(含历史 done)也参与:先用宿主/本地库回填 sid,再补 usage。
1094
+ if (entry.sessionId && !running && !missing) continue
1095
+ candidates.push(id)
1096
+ }
1097
+ if (candidates.length > 0) {
1098
+ const start = enrichCursor % candidates.length
1099
+ let budget = 4
1100
+ for (let k = 0; k < candidates.length && budget > 0; k++) {
1101
+ const id = candidates[(start + k) % candidates.length]
1102
+ let entry = next.get(id)
1103
+ if (!entry) continue
1104
+ if (!entry.sessionId) {
1105
+ try {
1106
+ const callId = String(entry.id).replace(/^tool:/, "")
1107
+ const sid = props.api.session.resolveChild?.({
1108
+ parentId: props.sessionId, callId, agent: entry.agent, startedAt: entry.startedAt,
1109
+ })
1110
+ if (sid) { entry = { ...entry, sessionId: sid }; next.set(id, entry); changed = true }
1111
+ } catch {}
1112
+ }
1113
+ if (!entry.sessionId) continue
1114
+ const childSid = entry.sessionId
1115
+ // 只从子会话读取,绝不读父会话。较旧的子会话
1116
+ // 可能不在本地缓存中,此时它们仍属于
1117
+ // 本面板的 map,读取是安全的。
689
1118
  let isChild = false
690
1119
  try {
691
- const s = props.api.session.get(entry.sessionId)
692
- isChild = s?.parentID === props.sessionId
693
- } catch {}
1120
+ const s = props.api.session.get(childSid)
1121
+ isChild = !s || s.parentID === props.sessionId
1122
+ } catch { isChild = true }
694
1123
  if (!isChild) continue
695
- const total = props.api.usage.readSessionTokens(entry.sessionId)
696
- const todo = props.api.usage.readSessionTodo(entry.sessionId)
697
- const model = entry.model ?? props.api.usage.readSessionModel(entry.sessionId)
1124
+ budget--
1125
+ attempted++
1126
+ const running = entry.status === "running" || entry.status === "cancel_requested"
1127
+ let entryChanged = false
698
1128
  const nextEntry: SubEntry = { ...entry }
699
- if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; changed = true }
700
- if (todo !== undefined) {
701
- if (todo.total !== entry.todoTotal || todo.done !== entry.todoDone) {
702
- nextEntry.todoTotal = todo.total; nextEntry.todoDone = todo.done; changed = true
1129
+ if (running || entry.tokens === undefined) {
1130
+ const total = props.api.usage.readSessionTokens(childSid)
1131
+ if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; entryChanged = true }
1132
+ }
1133
+ if (running || entry.cost === undefined) {
1134
+ const cost = props.api.usage.readSessionCost(childSid)
1135
+ if (cost !== undefined && cost !== entry.cost) { nextEntry.cost = cost; entryChanged = true }
1136
+ }
1137
+ if (running || !validModel(entry.model)) {
1138
+ const model = props.api.usage.readSessionModel(childSid)
1139
+ if (model && model !== entry.model) { nextEntry.model = model; entryChanged = true }
1140
+ }
1141
+ if (running) {
1142
+ const todo = props.api.usage.readSessionTodo(childSid)
1143
+ if (todo !== undefined && (todo.total !== entry.todoTotal || todo.done !== entry.todoDone)) {
1144
+ nextEntry.todoTotal = todo.total; nextEntry.todoDone = todo.done; entryChanged = true
703
1145
  }
704
1146
  }
705
- if (model && !entry.model) { nextEntry.model = model; changed = true }
706
- if (changed) next.set(id, nextEntry)
1147
+ if (entryChanged) { next.set(id, nextEntry); changed = true }
707
1148
  }
1149
+ enrichCursor = (start + Math.max(1, attempted)) % candidates.length
708
1150
  }
709
- return changed ? next : prev
1151
+ if (!changed) return prev
1152
+ enriched = next
1153
+ return next
710
1154
  })
1155
+ if (enriched) {
1156
+ // usage enrichment 是真实状态变更(cost/tokens/model)——通过与事件
1157
+ // 处理器相同的 merge-safe 路径持久化。
1158
+ globalEntryCache.set(props.sessionId, new Map(enriched))
1159
+ persistEntries(props.sessionId, enriched)
1160
+ }
711
1161
  })
1162
+ reconcileIdleEntries()
1163
+ // discovery poll(约每 2s):恢复从未通过宿主事件到达的
1164
+ // 子代理 part。与事件订阅的健康状况无关。
1165
+ if (tick % 4 === 0) {
1166
+ untrack(() => {
1167
+ if (missingNewestToolPart()) {
1168
+ runSessionScan(props.sessionId, false)
1169
+ bump()
1170
+ }
1171
+ })
1172
+ }
1173
+ // 周期性对账(约每 30s):存在 running 条目时与历史重扫一次,
1174
+ // 兜底丢失的 success / idle 事件(含消息同步后才可见的 part)。
1175
+ if (tick % 60 === 0) {
1176
+ untrack(() => {
1177
+ const hasRunning = entryList().some((e) => e.status === "running" || e.status === "cancel_requested")
1178
+ if (hasRunning) {
1179
+ runSessionScan(props.sessionId, false)
1180
+ bump()
1181
+ }
1182
+ })
1183
+ }
712
1184
  bump()
713
1185
  }, 500)
714
1186
  bump()
@@ -717,7 +1189,7 @@ export function SubAgentPanel(props: {
717
1189
  handlePartUpdated(e)
718
1190
  bump()
719
1191
  })
720
- const unsubMsg = props.api.event.on("message.updated", () => bump())
1192
+ const unsubMsg = props.api.event.on("message.updated", () => { bump(); scheduleRescan() })
721
1193
  const unsubIdle = props.api.event.on("session.idle", (e) => {
722
1194
  handleSessionEnd(e, "done")
723
1195
  bump()
@@ -729,6 +1201,7 @@ export function SubAgentPanel(props: {
729
1201
 
730
1202
  onCleanup(() => {
731
1203
  disposed = true
1204
+ clearTimeout(rescanTimer)
732
1205
  clearInterval(clock)
733
1206
  clearInterval(tokenTimer)
734
1207
  unsubPart()
@@ -750,7 +1223,11 @@ export function SubAgentPanel(props: {
750
1223
  const tick = clearTick() // 外部触发清除时 +1,effect 重跑
751
1224
  const forceReload = tick !== lastTick && !switched
752
1225
  lastTick = tick
753
- const t = setTimeout(() => {
1226
+
1227
+ let attempts = 0
1228
+ let retryTimer: ReturnType<typeof setTimeout> | undefined
1229
+
1230
+ const run = () => {
754
1231
  untrack(() => {
755
1232
  if (switched) {
756
1233
  const { parentSid, isChild } = resolveParent(sid)
@@ -761,150 +1238,28 @@ export function SubAgentPanel(props: {
761
1238
  setScrollOffset(saved)
762
1239
  // 刷新父会话的访问时间 TTL,防止活跃会话的数据过期
763
1240
  if (!isChild && data[sid]?.entries?.length) {
764
- data[sid].ts = Date.now()
765
- saveSessionData(data)
1241
+ void Promise.resolve(updateSessionData(props.api.kv, (latest) => {
1242
+ if (latest[sid]?.entries?.length) latest[sid].ts = Date.now()
1243
+ })).catch(() => {})
766
1244
  }
767
1245
  }
768
- // scan uses setEntryMapRaw — ephemeral data, not persisted to kv.
769
- // Only event-driven changes (handlePartUpdated, handleSessionEnd) persist.
770
- setEntryMapRaw((prev) => {
771
- // 优先从模块级缓存加载,KV 仅作缓存未命中时的回退
772
- const next = (switched || forceReload)
773
- ? new Map(globalEntryCache.get(sid) ?? loadEntries(sid))
774
- : new Map(prev)
775
- // 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目
776
- const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid)
777
- const scanRec = loadSessionData()[scanPSid]
778
- const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds)
779
- try {
780
- const msgs = props.api.session.messages(sid)
781
- if (msgs && (msgs as any[]).length) {
782
- for (const msg of msgs) {
783
- const parts = props.api.session.part((msg as any).id) ?? []
784
- for (const partRaw of parts) {
785
- const part = partRaw as Record<string, unknown>
786
-
787
- // Subtask entries are purely event-driven — never created by scan.
788
- // (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
789
- if (part.type === "tool") {
790
- const tool = String((part as any).tool ?? "")
791
- if (!SUBAGENT_TOOLS.has(tool)) continue
792
- const id = `tool:${String(part.id ?? "")}`
793
- if (!part.id) continue
794
-
795
- const st = (part as any).state as Record<string, unknown> | undefined
796
- const rawStatus = String(st?.status ?? "")
797
- const scanStMeta = st?.metadata as Record<string, unknown> | undefined
798
- const scanSubSid = scanStMeta?.session_id !== undefined ? String(scanStMeta.session_id)
799
- : scanStMeta?.sessionId !== undefined ? String(scanStMeta.sessionId)
800
- : undefined
801
- const existingKey = findSubEntryKey(next, id, scanSubSid)
802
- const exists = existingKey ? next.get(existingKey) : undefined
803
-
804
- // 已手动清除的条目:scan 发现但不在内存 → 跳过重建
805
- if (!exists && clearedIds.has(id)) continue
806
-
807
- // Only create entries for tool calls that entered execution.
808
- // "pending" / empty: skip new entries; allow heuristics for existing ones below.
809
- if ((rawStatus === "pending" || rawStatus === "") && !exists) continue
810
-
811
- // "error": only update existing, never create a new entry
812
- if (rawStatus === "error") {
813
- if (exists && exists.status === "running") {
814
- next.set(existingKey ?? id, { ...exists, status: "error", endedAt: Date.now() })
815
- }
816
- continue
817
- }
818
-
819
- let status: SubStatus = "running"
820
- if (rawStatus === "completed") status = "done"
821
- // Background tasks: tool completion ≠ agent completion — keep running until session.idle
822
- // Only keep running if state metadata confirms a child session was spawned.
823
- if (((st?.input as Record<string, unknown> | undefined)?.run_in_background === true || (st?.input as Record<string, unknown> | undefined)?.background === true) && status === "done") {
824
- const scanStMeta = st?.metadata as Record<string, unknown> | undefined
825
- const scanHasChild = scanStMeta?.session_id !== undefined || scanStMeta?.sessionId !== undefined
826
- if (scanHasChild) status = "running"
827
- }
828
-
829
- // Already settled → skip
830
- if (exists && existingKey === id && exists.status !== "running" && exists.status !== "cancel_requested") continue
831
- // Running entry with no explicit status improvement from part:
832
- // try message-level heuristics first, then time-based fallback.
833
- if (exists && status === "running") {
834
- if (!rawStatus) {
835
- const msgTokens = (msg as any)?.tokens as Record<string, unknown> | undefined
836
- if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
837
- status = "done" // LLM returned tokens → agent completed
838
- } else if (Date.now() - exists.startedAt > 30 * 60 * 1000) {
839
- status = "done" // >30 min idle → assume completed
840
- } else {
841
- continue
842
- }
843
- } else {
844
- continue
845
- }
846
- }
847
-
848
- // If already tracked as running but tool state says completed/error → update
849
- // If not tracked → add fresh
850
-
851
- const input = st?.input as Record<string, unknown> | undefined
852
- const agent = String((part as any).subagent_type ?? input?.agent ?? input?.subagent_type ?? tool)
853
- const prompt = String(input?.prompt ?? (part as any).description ?? "")
854
- const desc = input?.description !== undefined ? String(input.description) : ""
855
- const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40)
856
-
857
- let tokens: number | undefined
858
- if (scanSubSid) tokens = props.api.usage.readSessionTokens(scanSubSid)
859
-
860
- const ended = status === "done" // "error" handled above, never reaches here
861
- const entryKey = existingKey ?? id
862
- next.set(entryKey, {
863
- id: exists?.id ?? id, title, agent, prompt,
864
- // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
865
- tokens: exists?.tokens ?? tokens,
866
- sessionId: exists?.sessionId ?? scanSubSid,
867
- status,
868
- startedAt: exists?.startedAt || Date.now(),
869
- endedAt: ended ? (exists?.endedAt || Date.now()) : undefined,
870
- })
871
- if (entryKey !== id) next.delete(id)
872
- }
873
- }
874
- }
875
- }
876
- } catch {}
877
- return next
878
- })
879
- // Reconcile: check running entries against live child session status.
880
- // Covers session.idle events missed while user was inside a child session.
881
- setEntryMapRaw((prev) => {
882
- let changed = false
883
- const next = new Map(prev)
884
- for (const [id, entry] of next) {
885
- if ((entry.status !== "running" && entry.status !== "cancel_requested") || !entry.sessionId) continue
886
- try {
887
- const st = props.api.session.status(entry.sessionId)
888
- if (!st || st.type !== "idle") continue
889
- const tokens = props.api.usage.readSessionTokens(entry.sessionId)
890
- const cost = props.api.usage.readSessionCost(entry.sessionId)
891
- const finalStatus = entry.status === "cancel_requested" && entry.abortAccepted
892
- ? "cancelled" as SubStatus
893
- : "done" as SubStatus
894
- next.set(id, {
895
- ...entry, status: finalStatus, endedAt: Date.now(),
896
- tokens: tokens ?? entry.tokens,
897
- cost: cost ?? entry.cost,
898
- })
899
- changed = true
900
- } catch {}
901
- }
902
- return changed ? next : prev
903
- })
904
- bump()
1246
+ const ok = runSessionScan(sid, switched || forceReload)
1247
+ // 首次挂载时宿主消息缓存可能尚未就绪;
1248
+ // 重试几次,避免面板一直空着直到
1249
+ // 下一次切换会话。
1250
+ if (!ok && attempts < 4) {
1251
+ attempts++
1252
+ retryTimer = setTimeout(run, 400)
1253
+ }
905
1254
  })
906
- }, 150)
907
- onCleanup(() => clearTimeout(t))
1255
+ bump()
1256
+ }
1257
+
1258
+ const t = setTimeout(run, 150)
1259
+ onCleanup(() => {
1260
+ clearTimeout(t)
1261
+ if (retryTimer) clearTimeout(retryTimer)
1262
+ })
908
1263
  })
909
1264
 
910
1265
  // ── palette ──
@@ -1225,22 +1580,35 @@ export function SubAgentPanel(props: {
1225
1580
 
1226
1581
  // Entry label: collapsed shows title only, expanded shows title only too
1227
1582
  const tokenText = () =>
1228
- !isExpanded() && entry.tokens !== undefined && entry.tokens > 0
1583
+ !isExpanded() && props.showEntryTokens() && entry.tokens !== undefined && entry.tokens > 0
1229
1584
  ? ` ${fmtTokens(entry.tokens!)}`
1230
1585
  : ""
1586
+ const costText = () =>
1587
+ !isExpanded() && props.showEntryCost() && entry.cost !== undefined && entry.cost > 0
1588
+ ? ` $${entry.cost.toFixed(4)}`
1589
+ : ""
1231
1590
  const timeText = () =>
1232
- !isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined)
1233
- ? fmtDurationShort(elapsed(), isActiveRunning)
1591
+ !isExpanded() && props.showEntryTime() && (elapsed() >= 2000 || entry.endedAt !== undefined)
1592
+ ? fmtDuration(elapsed(), isActiveRunning, props.timeFormat())
1234
1593
  : ""
1594
+ // 来源标记:⇢ = 后台衍生(工具输入 background),↳ = 衍生会话(spawn)。
1595
+ const originMark = () => {
1596
+ if (!props.showOrigin()) return ""
1597
+ if (entry.origin === "sub") return "\u21b3"
1598
+ return entry.background === true ? "\u21e2" : ""
1599
+ }
1600
+ const originW = () => (originMark() ? 2 : 0)
1235
1601
  const suffixW = () => {
1236
1602
  let w = 0
1237
1603
  const t = timeText()
1238
1604
  if (t) w += 1 + visualWidth(t)
1239
1605
  const tk = tokenText()
1240
1606
  if (tk) w += visualWidth(tk)
1607
+ const c = costText()
1608
+ if (c) w += visualWidth(c)
1241
1609
  return w
1242
1610
  }
1243
- const labelAvail = () => Math.max(6, panelWidth() - gutter() - LEFT_PAD - suffixW())
1611
+ const labelAvail = () => Math.max(6, panelWidth() - gutter() - LEFT_PAD - originW() - suffixW())
1244
1612
  const labelText = () => {
1245
1613
  const max = labelAvail()
1246
1614
  const text = entry.title || entry.agent
@@ -1259,6 +1627,7 @@ export function SubAgentPanel(props: {
1259
1627
  {" "}
1260
1628
  <span style={{ fg: statusColor() }}>{statusDot()}</span>
1261
1629
  {" "}
1630
+ {originMark() ? <span style={{ fg: pal().muted }}>{originMark() + " "}</span> : null}
1262
1631
  <span style={{ fg: pal().text }}>{labelText()}</span>
1263
1632
  {timeText() ? (
1264
1633
  <>
@@ -1269,6 +1638,9 @@ export function SubAgentPanel(props: {
1269
1638
  {tokenText() ? (
1270
1639
  <span style={{ fg: pal().muted }}>{tokenText()}</span>
1271
1640
  ) : null}
1641
+ {costText() ? (
1642
+ <span style={{ fg: pal().warning }}>{costText()}</span>
1643
+ ) : null}
1272
1644
  </text>
1273
1645
 
1274
1646
  {/* expanded detail — right-aligned values */}
@@ -1279,6 +1651,17 @@ export function SubAgentPanel(props: {
1279
1651
  <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("agent.label")))}</span>
1280
1652
  <span style={{ fg: pal().muted }}>{entry.agent}</span>
1281
1653
  </text>
1654
+ <Show when={props.showOrigin()}>
1655
+ <text>
1656
+ {" "}
1657
+ <span style={{ fg: pal().primary }}>{t("origin.label")}: </span>
1658
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("origin.label")))}</span>
1659
+ <span style={{ fg: pal().muted }}>
1660
+ {entry.origin === "sub" ? t("origin.sub") : t("origin.tool")}
1661
+ {entry.background === true ? ` (${t("origin.background")})` : ""}
1662
+ </span>
1663
+ </text>
1664
+ </Show>
1282
1665
  <text>
1283
1666
  {" "}
1284
1667
  <span style={{ fg: pal().primary }}>{t("status.label")}: </span>
@@ -1293,7 +1676,7 @@ export function SubAgentPanel(props: {
1293
1676
  <span style={{ fg: pal().primary }}>{t("time.label")}: </span>
1294
1677
  <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("time.label")))}</span>
1295
1678
  <span style={{ fg: pal().muted }}>
1296
- {fmtDurationShort(elapsed(), isActiveRunning)}
1679
+ {fmtDuration(elapsed(), isActiveRunning, props.timeFormat())}
1297
1680
  </span>
1298
1681
  </text>
1299
1682
  </Show>
@@ -1326,7 +1709,7 @@ export function SubAgentPanel(props: {
1326
1709
  )
1327
1710
  })()}
1328
1711
  </Show>
1329
- <Show when={entry.model}>
1712
+ <Show when={validModel(entry.model) ? entry.model : undefined}>
1330
1713
  <text>
1331
1714
  {" "}
1332
1715
  <span style={{ fg: pal().primary }}>{t("model.label")}: </span>