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