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
@@ -4,12 +4,12 @@ import { PLUGIN_VERSION } from "../_version";
4
4
  import { copyText } from "../clipboard";
5
5
  import { createT } from "../i18n";
6
6
  import { SUBAGENT_TOOLS } from "../core/types";
7
- import { visualWidth, truncate, fmtDurationShort, fmtTokens, safeErrorMsg } from "../core/format";
7
+ import { visualWidth, truncate, fmtDuration, fmtTokens, safeErrorMsg } from "../core/format";
8
8
  import { rgb, desaturateTo, dimColor, FALLBACK, MAX_SAT } from "../core/color";
9
- import { KV_PREFIX } from "../core/kv";
9
+ import { KV_PREFIX, updateSessionData } from "../core/kv";
10
10
  import { globalEntryCache, clearTick } from "./store";
11
11
  import { isDirectChildSession } from "./session-routing";
12
- import { findSubEntryKey, upsertSubEntry } from "./entry-map";
12
+ import { findSubEntryKey, mergeSubEntries, upsertSubEntry } from "./entry-map";
13
13
  /** Entry line left prefix: icon + space + status dot + space */
14
14
  const LEFT_PAD = 4;
15
15
  /** Detail row indent: two spaces */
@@ -17,6 +17,22 @@ const INDENT = 2;
17
17
  // ===================================================================
18
18
  // Sidebar component
19
19
  // ===================================================================
20
+ /** 读取 part/state 两级的 tool metadata(TUI 数据层放在 part 级,旧形状嵌在 state 里)。 */
21
+ function partMetadata(part, st) {
22
+ const partMeta = part?.metadata;
23
+ if (partMeta && Object.keys(partMeta).length > 0)
24
+ return partMeta;
25
+ const stateMeta = st?.metadata;
26
+ return stateMeta && Object.keys(stateMeta).length > 0 ? stateMeta : undefined;
27
+ }
28
+ /** model 字段的有效值——历史 KV 里可能混入 "[object Object]" 这类序列化垃圾。 */
29
+ function validModel(model) {
30
+ if (typeof model !== "string")
31
+ return false;
32
+ const s = model.trim();
33
+ // 历史脏值:String(对象) 与未解析的 JSON 文本(session_v2.model 的形态)。
34
+ return s.length > 0 && s !== "[object Object]" && !s.startsWith("{");
35
+ }
20
36
  export function SubAgentPanel(props) {
21
37
  const t = createT(() => props.lang());
22
38
  // ── session data (single-key, true deletion on cleanup) ──
@@ -33,11 +49,31 @@ export function SubAgentPanel(props) {
33
49
  return {};
34
50
  }
35
51
  };
36
- const saveSessionData = (data) => {
37
- try {
38
- props.api.kv.set(SESSION_DATA_KEY, JSON.stringify(data));
39
- }
40
- catch { }
52
+ /**
53
+ * 持久化存储的原子 read-merge-write:所有写入都经过这里。
54
+ * 多个 TUI 共用一个 KV 文件时,直接写入内存快照
55
+ * 会丢掉其他实例创建的条目。
56
+ */
57
+ const mutateSessionData = (mutator) => {
58
+ // 宿主存储可能异步 reject;吞掉以免失败写入变成未处理 rejection 拖垮 TUI。
59
+ void Promise.resolve(updateSessionData(props.api.kv, mutator)).catch(() => { });
60
+ };
61
+ /** 带合理默认值的 SessionRecord(记录可能在首次写入时创建)。 */
62
+ const ensureRootRecord = (data, sid) => {
63
+ const rec = data[sid];
64
+ if (rec)
65
+ return rec;
66
+ const created = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} };
67
+ data[sid] = created;
68
+ return created;
69
+ };
70
+ const ensureChildRecord = (data, parentSid, sid) => {
71
+ const parent = ensureRootRecord(data, parentSid);
72
+ if (!parent.children)
73
+ parent.children = {};
74
+ if (!parent.children[sid])
75
+ parent.children[sid] = { scroll: 0, expanded: "", entries: [] };
76
+ return parent.children[sid];
41
77
  };
42
78
  /** 将任意 session ID 解析为父会话 ID + 是否子会话。
43
79
  * 通过 SDK session.get(sid).parentID 判断,无 parentID 即为主会话。 */
@@ -73,62 +109,53 @@ export function SubAgentPanel(props) {
73
109
  clearTimeout(persistTimer);
74
110
  persistTimer = setTimeout(() => {
75
111
  try {
76
- const data = loadSessionData();
77
- const { parentSid, isChild } = resolveParent(sid);
78
- if (isChild) {
79
- if (!data[parentSid])
80
- data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} };
81
- if (!data[parentSid].children)
82
- data[parentSid].children = {};
83
- if (!data[parentSid].children[sid])
84
- data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] };
85
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], entries: [...entries.values()] };
86
- }
87
- else {
88
- data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()], children: data[sid]?.children ?? {} };
89
- }
90
- saveSessionData(data);
112
+ mutateSessionData((data) => {
113
+ const { parentSid, isChild } = resolveParent(sid);
114
+ if (isChild) {
115
+ const parent = ensureRootRecord(data, parentSid);
116
+ const child = ensureChildRecord(data, parentSid, sid);
117
+ child.entries = [...mergeSubEntries(child.entries ?? [], entries.values()).values()];
118
+ parent.ts = Date.now();
119
+ }
120
+ else {
121
+ const rec = ensureRootRecord(data, sid);
122
+ rec.ts = Date.now();
123
+ rec.entries = [...mergeSubEntries(rec.entries ?? [], entries.values()).values()];
124
+ }
125
+ });
91
126
  }
92
127
  catch { }
93
128
  }, 200);
94
129
  };
95
130
  const persistScroll = (sid, scroll) => {
96
131
  try {
97
- const data = loadSessionData();
98
- const { parentSid, isChild } = resolveParent(sid);
99
- if (isChild) {
100
- if (!data[parentSid])
101
- data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} };
102
- if (!data[parentSid].children)
103
- data[parentSid].children = {};
104
- if (!data[parentSid].children[sid])
105
- data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] };
106
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], scroll };
107
- }
108
- else {
109
- data[sid] = { ...data[sid], ts: Date.now(), scroll, children: data[sid]?.children ?? {} };
110
- }
111
- saveSessionData(data);
132
+ mutateSessionData((data) => {
133
+ const { parentSid, isChild } = resolveParent(sid);
134
+ if (isChild) {
135
+ ensureChildRecord(data, parentSid, sid).scroll = scroll;
136
+ }
137
+ else {
138
+ const rec = ensureRootRecord(data, sid);
139
+ rec.ts = Date.now();
140
+ rec.scroll = scroll;
141
+ }
142
+ });
112
143
  }
113
144
  catch { }
114
145
  };
115
146
  const persistExpanded = (sid, expanded) => {
116
147
  try {
117
- const data = loadSessionData();
118
- const { parentSid, isChild } = resolveParent(sid);
119
- if (isChild) {
120
- if (!data[parentSid])
121
- data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} };
122
- if (!data[parentSid].children)
123
- data[parentSid].children = {};
124
- if (!data[parentSid].children[sid])
125
- data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] };
126
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], expanded };
127
- }
128
- else {
129
- data[sid] = { ...data[sid], ts: Date.now(), expanded, children: data[sid]?.children ?? {} };
130
- }
131
- saveSessionData(data);
148
+ mutateSessionData((data) => {
149
+ const { parentSid, isChild } = resolveParent(sid);
150
+ if (isChild) {
151
+ ensureChildRecord(data, parentSid, sid).expanded = expanded;
152
+ }
153
+ else {
154
+ const rec = ensureRootRecord(data, sid);
155
+ rec.ts = Date.now();
156
+ rec.expanded = expanded;
157
+ }
158
+ });
132
159
  }
133
160
  catch { }
134
161
  };
@@ -136,17 +163,13 @@ export function SubAgentPanel(props) {
136
163
  if (ttlDays <= 0)
137
164
  return; // 无期限,跳过清理
138
165
  try {
139
- const data = loadSessionData();
140
166
  const cutoff = Date.now() - TTL_MS;
141
- let changed = false;
142
- for (const sid of Object.keys(data)) {
143
- if (data[sid].ts < cutoff) {
144
- delete data[sid];
145
- changed = true;
167
+ mutateSessionData((data) => {
168
+ for (const sid of Object.keys(data)) {
169
+ if (data[sid].ts < cutoff)
170
+ delete data[sid];
146
171
  }
147
- }
148
- if (changed)
149
- saveSessionData(data);
172
+ });
150
173
  }
151
174
  catch { }
152
175
  };
@@ -169,21 +192,20 @@ export function SubAgentPanel(props) {
169
192
  if (needsImmediateFlush) {
170
193
  clearTimeout(persistTimer);
171
194
  try {
172
- const data = loadSessionData();
173
- const { parentSid, isChild } = resolveParent(props.sessionId);
174
- if (isChild) {
175
- if (!data[parentSid])
176
- data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} };
177
- if (!data[parentSid].children)
178
- data[parentSid].children = {};
179
- if (!data[parentSid].children[props.sessionId])
180
- data[parentSid].children[props.sessionId] = { scroll: 0, expanded: "", entries: [] };
181
- data[parentSid].children[props.sessionId] = { ...data[parentSid].children[props.sessionId], entries: [...next.values()] };
182
- }
183
- else {
184
- data[props.sessionId] = { ...data[props.sessionId], ts: Date.now(), entries: [...next.values()], children: data[props.sessionId]?.children ?? {} };
185
- }
186
- saveSessionData(data);
195
+ mutateSessionData((data) => {
196
+ const { parentSid, isChild } = resolveParent(props.sessionId);
197
+ if (isChild) {
198
+ const parent = ensureRootRecord(data, parentSid);
199
+ const child = ensureChildRecord(data, parentSid, props.sessionId);
200
+ child.entries = [...mergeSubEntries(child.entries ?? [], next.values()).values()];
201
+ parent.ts = Date.now();
202
+ }
203
+ else {
204
+ const rec = ensureRootRecord(data, props.sessionId);
205
+ rec.ts = Date.now();
206
+ rec.entries = [...mergeSubEntries(rec.entries ?? [], next.values()).values()];
207
+ }
208
+ });
187
209
  }
188
210
  catch { }
189
211
  }
@@ -327,8 +349,9 @@ export function SubAgentPanel(props) {
327
349
  // V2 事件流是全局的——若 payload 带发起会话 ID,则只归账到正在查看的会话,
328
350
  // 避免其他会话的子代理写进当前侧边栏;V1 宿主已按会话 scope,不传 sessionID。
329
351
  const eventSid = event.payload?.sessionID !== undefined ? String(event.payload.sessionID) : undefined;
330
- if (event.scope === "global" && eventSid !== props.sessionId)
352
+ if (event.scope === "global" && eventSid !== props.sessionId) {
331
353
  return;
354
+ }
332
355
  // SubtaskPart
333
356
  if (part.type === "subtask") {
334
357
  const agent = String(part.agent ?? "?");
@@ -359,7 +382,7 @@ export function SubAgentPanel(props) {
359
382
  const id = `tool:${String(part.id ?? "")}`;
360
383
  if (!part.id)
361
384
  return;
362
- const stMeta = st?.metadata;
385
+ const stMeta = partMetadata(part, st);
363
386
  const errorSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
364
387
  : stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
365
388
  : undefined;
@@ -386,7 +409,7 @@ export function SubAgentPanel(props) {
386
409
  // Only keep running if state metadata confirms a child session was spawned;
387
410
  // otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever.
388
411
  if ((input?.run_in_background === true || input?.background === true) && status === "done") {
389
- const stMetaCheck = st?.metadata;
412
+ const stMetaCheck = partMetadata(part, st);
390
413
  const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined;
391
414
  if (hasChild)
392
415
  status = "running";
@@ -398,7 +421,7 @@ export function SubAgentPanel(props) {
398
421
  const id = `tool:${String(part.id ?? crypto.randomUUID())}`;
399
422
  // Child session ID lives in state-level metadata (ToolStateCompleted.metadata),
400
423
  // injected by the tool executor. ToolPart.sessionID is the parent session.
401
- const stMeta = st?.metadata;
424
+ const stMeta = partMetadata(part, st);
402
425
  const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
403
426
  : stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
404
427
  : undefined;
@@ -598,22 +621,34 @@ export function SubAgentPanel(props) {
598
621
  if (!found) {
599
622
  const data = loadSessionData();
600
623
  const rec = data[parentSid];
601
- if (rec?.entries) {
624
+ if (rec?.entries?.length) {
602
625
  const fallbackMap = new Map(rec.entries.map((e) => [e.id, e]));
603
626
  found = tryMatchAndUpdate(fallbackMap, sid, status, nowTs);
604
627
  if (found) {
605
- // 回退命中后写入 KV 并回填缓存
606
- data[parentSid] = { ...rec, ts: nowTs, entries: [...fallbackMap.values()] };
607
- saveSessionData(data);
628
+ // 回退命中后写入 KV 并回填缓存。
629
+ // merge(而非整表替换):其他实例写入的新条目不能被旧缓存覆盖。
608
630
  globalEntryCache.set(parentSid, fallbackMap);
631
+ mutateSessionData((latest) => {
632
+ const latestRec = latest[parentSid];
633
+ latest[parentSid] = {
634
+ ...(latestRec ?? rec),
635
+ ts: nowTs,
636
+ entries: [...mergeSubEntries(latestRec?.entries ?? [], fallbackMap.values()).values()],
637
+ };
638
+ });
609
639
  }
610
640
  }
611
641
  }
612
- // 将模块级缓存中的最新状态同步到 KV
642
+ // 将模块级缓存中的最新状态 merge 进 KV(缓存可能缺少其他实例的新条目)
613
643
  if (found && parentCache) {
614
- const data = loadSessionData();
615
- data[parentSid] = { ...data[parentSid], ts: nowTs, entries: [...parentCache.values()] };
616
- saveSessionData(data);
644
+ mutateSessionData((data) => {
645
+ const rec = data[parentSid];
646
+ data[parentSid] = {
647
+ ...(rec ?? { entries: [], scroll: 0, expanded: "", children: {}, ts: nowTs }),
648
+ ts: nowTs,
649
+ entries: [...mergeSubEntries(rec?.entries ?? [], parentCache.values()).values()],
650
+ };
651
+ });
617
652
  }
618
653
  }
619
654
  }
@@ -651,52 +686,441 @@ export function SubAgentPanel(props) {
651
686
  };
652
687
  // ── bumpRenderTick: force re-render (visual-cache pattern) ──
653
688
  const bump = () => setRenderTick((v) => v + 1);
689
+ // ── history scan(mount/switch、重试与消息驱动 rescan 共用)──
690
+ /** 将子会话已 idle 的 running 条目标记为已落定。
691
+ * 纯 map 变换——是否持久化由调用方决定。 */
692
+ const settleIdleEntries = (prev) => {
693
+ let changed = false;
694
+ const next = new Map(prev);
695
+ for (const [id, entry] of next) {
696
+ if (entry.status !== "running" && entry.status !== "cancel_requested")
697
+ continue;
698
+ // 无链接条目:先用宿主/本地库按 call id 回填子会话 id;
699
+ // 回填不了就维持原状(不猜测、不设时间阈值)。
700
+ let sid = entry.sessionId;
701
+ if (!sid) {
702
+ try {
703
+ const callId = String(entry.id).replace(/^tool:/, "");
704
+ sid = props.api.session.resolveChild?.({
705
+ parentId: props.sessionId, callId, agent: entry.agent, startedAt: entry.startedAt,
706
+ });
707
+ }
708
+ catch { }
709
+ if (!sid)
710
+ continue;
711
+ next.set(id, { ...entry, sessionId: sid });
712
+ changed = true;
713
+ }
714
+ try {
715
+ const st = props.api.session.status(sid);
716
+ if (!st || st.type !== "idle")
717
+ continue;
718
+ const tokens = props.api.usage.readSessionTokens(sid);
719
+ const cost = props.api.usage.readSessionCost(sid);
720
+ const finalStatus = entry.status === "cancel_requested" && entry.abortAccepted
721
+ ? "cancelled"
722
+ : "done";
723
+ next.set(id, {
724
+ ...entry, sessionId: sid, status: finalStatus, endedAt: Date.now(),
725
+ tokens: tokens ?? entry.tokens,
726
+ cost: cost ?? entry.cost,
727
+ });
728
+ changed = true;
729
+ }
730
+ catch { }
731
+ }
732
+ return changed ? next : prev;
733
+ };
734
+ /** 周期性兜底遗漏的 session.idle 事件(僵尸 running 条目)。
735
+ * 由 500ms 定时器驱动,即使没有事件到达也能让条目落定。 */
736
+ const reconcileIdleEntries = () => {
737
+ try {
738
+ const before = entryMap();
739
+ const after = settleIdleEntries(before);
740
+ if (after !== before)
741
+ setEntryMap(after);
742
+ }
743
+ catch { }
744
+ };
745
+ /** 扫描会话历史并 merge 进 entry map。
746
+ * 消息数据尚不可用时返回 false,供调用方重试。 */
747
+ const runSessionScan = (sid, replace) => {
748
+ let msgs;
749
+ try {
750
+ msgs = props.api.session.messages(sid);
751
+ }
752
+ catch { }
753
+ if (!msgs || msgs.length === 0)
754
+ return false;
755
+ // scan 结果先写入内存(setEntryMapRaw);scan 末尾统一走 persistEntries 的 merge-safe 落盘。
756
+ setEntryMapRaw((prev) => {
757
+ // 优先从模块级缓存加载,KV 仅作缓存未命中时的回退
758
+ const next = replace
759
+ ? new Map(globalEntryCache.get(sid) ?? loadEntries(sid))
760
+ : new Map(prev);
761
+ // 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目
762
+ const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid);
763
+ const scanRec = loadSessionData()[scanPSid];
764
+ const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds);
765
+ try {
766
+ for (const msg of msgs) {
767
+ const parts = props.api.session.part(msg.id) ?? [];
768
+ for (const partRaw of parts) {
769
+ const part = partRaw;
770
+ // Subtask entries are purely event-driven — never created by scan.
771
+ // (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
772
+ if (part.type === "tool") {
773
+ const tool = String(part.tool ?? "");
774
+ if (!SUBAGENT_TOOLS.has(tool))
775
+ continue;
776
+ const id = `tool:${String(part.id ?? "")}`;
777
+ if (!part.id)
778
+ continue;
779
+ const st = part.state;
780
+ const rawStatus = String(st?.status ?? "");
781
+ const scanInput = st?.input;
782
+ // TUI 数据层把子代理 metadata 放在 part 级;旧缓存
783
+ // 形状可能嵌在 state 里。两处都读。
784
+ const scanMeta = partMetadata(part, st);
785
+ let scanSubSid = scanMeta?.session_id !== undefined ? String(scanMeta.session_id)
786
+ : scanMeta?.sessionId !== undefined ? String(scanMeta.sessionId)
787
+ : scanMeta?.sessionID !== undefined ? String(scanMeta.sessionID)
788
+ : undefined;
789
+ // 宿主缓存/历史缺少子会话链接时,用本地库按工具 call id 回填。
790
+ if (!scanSubSid) {
791
+ try {
792
+ scanSubSid = props.api.session.resolveChild?.({ parentId: sid, callId: String(part.id) });
793
+ }
794
+ catch { }
795
+ }
796
+ const existingKey = findSubEntryKey(next, id, scanSubSid);
797
+ const exists = existingKey ? next.get(existingKey) : undefined;
798
+ // 已手动清除的条目:scan 发现但不在内存 → 跳过重建
799
+ if (!exists && clearedIds.has(id))
800
+ continue;
801
+ // Only create entries for tool calls that entered execution.
802
+ // "pending" / empty: skip new entries; allow heuristics for existing ones below.
803
+ if ((rawStatus === "pending" || rawStatus === "") && !exists)
804
+ continue;
805
+ // "error": only update existing, never create a new entry
806
+ if (rawStatus === "error") {
807
+ if (exists && exists.status === "running") {
808
+ next.set(existingKey ?? id, { ...exists, status: "error", endedAt: Date.now() });
809
+ }
810
+ continue;
811
+ }
812
+ // 已落定的条目:仅在补全缺失数据时回访
813
+ // (session/tokens/cost/model)。其终态必须保持。
814
+ const settled = exists !== undefined &&
815
+ exists.status !== "running" &&
816
+ exists.status !== "cancel_requested";
817
+ const needsRepair = settled &&
818
+ (exists.sessionId === undefined ||
819
+ exists.tokens === undefined ||
820
+ exists.cost === undefined ||
821
+ !validModel(exists.model));
822
+ if (settled && !needsRepair)
823
+ continue;
824
+ let status = "running";
825
+ if (rawStatus === "completed")
826
+ status = "done";
827
+ // Background tasks: tool completion ≠ agent completion — keep running until session.idle
828
+ // 仅在 metadata 确认已派生子会话时保持 running。
829
+ if ((scanInput?.run_in_background === true || scanInput?.background === true) && status === "done") {
830
+ if (scanSubSid !== undefined)
831
+ status = "running";
832
+ }
833
+ // 子会话存活判定:工具调用已完成但子会话仍 busy,
834
+ // 说明子代理本身仍在运行(覆盖缓存 part 中
835
+ // 缺少 background 标志的 input)。
836
+ if (status === "done" && scanSubSid) {
837
+ try {
838
+ const childStatus = props.api.session.status(scanSubSid);
839
+ if (childStatus && childStatus.type !== "idle")
840
+ status = "running";
841
+ }
842
+ catch { }
843
+ }
844
+ if (settled) {
845
+ // 修复流程:保留已记录的终态。
846
+ status = exists.status;
847
+ }
848
+ else if (exists && status === "running") {
849
+ // Running entry with no explicit status improvement from part:
850
+ // try message-level heuristics first, then time-based fallback.
851
+ if (!rawStatus) {
852
+ const msgTokens = msg?.tokens;
853
+ if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
854
+ status = "done"; // LLM returned tokens → agent completed
855
+ }
856
+ else {
857
+ // 无终态证据时不猜测:等本地库/事件回填子会话状态后由 reconcile 落定。
858
+ continue;
859
+ }
860
+ }
861
+ else {
862
+ continue;
863
+ }
864
+ }
865
+ // If already tracked as running but tool state says completed/error → update
866
+ // If not tracked → add fresh
867
+ const agent = String(part.subagent_type ?? scanInput?.agent ?? scanInput?.subagent_type ?? tool);
868
+ const prompt = String(scanInput?.prompt ?? part.description ?? "");
869
+ const desc = scanInput?.description !== undefined ? String(scanInput.description) : "";
870
+ const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40);
871
+ let tokens;
872
+ if (scanSubSid)
873
+ tokens = props.api.usage.readSessionTokens(scanSubSid);
874
+ let cost;
875
+ if (scanSubSid)
876
+ cost = props.api.usage.readSessionCost(scanSubSid);
877
+ let model;
878
+ if (scanSubSid)
879
+ model = props.api.usage.readSessionModel(scanSubSid);
880
+ const entryKey = existingKey ?? id;
881
+ next.set(entryKey, {
882
+ ...(exists ?? { id }),
883
+ title: title || exists?.title || "",
884
+ agent: exists?.agent ?? agent,
885
+ prompt: prompt || exists?.prompt || "",
886
+ // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
887
+ tokens: exists?.tokens ?? tokens,
888
+ cost: exists?.cost ?? cost,
889
+ model: validModel(exists?.model) ? exists.model : model,
890
+ sessionId: exists?.sessionId ?? scanSubSid,
891
+ status,
892
+ startedAt: exists?.startedAt || Date.now(),
893
+ endedAt: exists?.endedAt ?? (status === "done" ? Date.now() : undefined),
894
+ });
895
+ if (entryKey !== id)
896
+ next.delete(id);
897
+ }
898
+ }
899
+ }
900
+ }
901
+ catch { }
902
+ return next;
903
+ });
904
+ // Reconcile: check running entries against live child session status.
905
+ // Covers session.idle events missed while user was inside a child session.
906
+ setEntryMapRaw((prev) => settleIdleEntries(prev));
907
+ // 持久化 scan 修复结果(merge-safe:缺失字段始终以 KV 现有值为准),
908
+ // 让恢复的条目在重新挂载后仍在,并能同步到其他实例。
909
+ const snapshot = entryMap();
910
+ if (snapshot.size > 0) {
911
+ globalEntryCache.set(sid, new Map(snapshot));
912
+ persistEntries(sid, snapshot);
913
+ }
914
+ return true;
915
+ };
916
+ let rescanTimer;
917
+ /** 当最新消息与 entry map 不一致时为 true:子代理 tool part 尚未建条目,
918
+ * 或非后台 tool 已完成而条目仍停在 running。
919
+ * 事件驱动 rescan 与周期性 discovery poll 共用的轻量检查:
920
+ * 即使宿主的所有事件订阅都静默失效,
921
+ * 也能恢复条目。 */
922
+ const missingNewestToolPart = () => {
923
+ try {
924
+ const msgs = props.api.session.messages(props.sessionId);
925
+ if (!msgs || msgs.length === 0)
926
+ return false;
927
+ const map = entryMap();
928
+ const from = Math.max(0, msgs.length - 6);
929
+ for (let i = msgs.length - 1; i >= from; i--) {
930
+ const parts = props.api.session.part(msgs[i]?.id) ?? [];
931
+ for (const part of parts) {
932
+ if (!part || part.type !== "tool")
933
+ continue;
934
+ if (!SUBAGENT_TOOLS.has(String(part.tool ?? "")))
935
+ continue;
936
+ const partId = String(part.id ?? "");
937
+ if (!partId)
938
+ continue;
939
+ const st = part.state;
940
+ const rawStatus = String(st?.status ?? "");
941
+ // scan 从不为 pending/error part 创建条目;
942
+ // 这里忽略它们可避免对一直保持该状态的 part 反复 rescan。
943
+ if (rawStatus !== "running" && rawStatus !== "completed")
944
+ continue;
945
+ const id = `tool:${partId}`;
946
+ const meta = partMetadata(part, st);
947
+ const subSid = meta?.session_id !== undefined ? String(meta.session_id)
948
+ : meta?.sessionId !== undefined ? String(meta.sessionId)
949
+ : undefined;
950
+ const key = findSubEntryKey(map, id, subSid);
951
+ const entry = key ? map.get(key) : undefined;
952
+ if (!entry)
953
+ return true;
954
+ // 非后台 tool 已完成、条目却仍停在 running:补一次 scan 收尾
955
+ // (success 事件可能因缺少 metadata 被适配层丢弃)。
956
+ const input = st?.input;
957
+ const isBackground = input?.background === true || input?.run_in_background === true;
958
+ const stillRunning = entry.status === "running" || entry.status === "cancel_requested";
959
+ if (rawStatus === "completed" && !isBackground && stillRunning)
960
+ return true;
961
+ }
962
+ }
963
+ }
964
+ catch { }
965
+ return false;
966
+ };
967
+ /** 新消息到达时防抖重扫。覆盖 scan 早于宿主消息缓存
968
+ * 加载完成的竞态。 */
969
+ const scheduleRescan = () => {
970
+ if (disposed)
971
+ return;
972
+ clearTimeout(rescanTimer);
973
+ rescanTimer = setTimeout(() => {
974
+ if (disposed)
975
+ return;
976
+ untrack(() => {
977
+ if (!missingNewestToolPart())
978
+ return;
979
+ runSessionScan(props.sessionId, false);
980
+ bump();
981
+ });
982
+ }, 400);
983
+ };
984
+ // usage enrichment 轮询的 round-robin 游标。
985
+ let enrichCursor = 0;
654
986
  onMount(() => {
655
987
  // Fast clock for smooth time display, separate from token polling
656
988
  const clock = setInterval(() => { setNow(Date.now()); bump(); }, 100);
657
989
  // Token poll — runs every 500ms for running entries
990
+ let tick = 0;
658
991
  const tokenTimer = setInterval(() => {
992
+ tick++;
659
993
  untrack(() => {
994
+ let enriched;
995
+ let attempted = 0;
660
996
  setEntryMapRaw((prev) => {
661
997
  let changed = false;
662
998
  const next = new Map(prev);
999
+ // 候选:仍缺 usage 数据的条目。以 round-robin 遍历,
1000
+ // 避免一串读不到数据的会话(如 free-model 子会话)
1001
+ // 饿死列表其余部分;每个 tick 只读取少数会话。
1002
+ const candidates = [];
663
1003
  for (const [id, entry] of next) {
664
- if (entry.status === "running" && entry.sessionId) {
665
- // Only read from child sessions, never the parent
1004
+ const running = entry.status === "running" || entry.status === "cancel_requested";
1005
+ const missing = entry.tokens === undefined || entry.cost === undefined || !validModel(entry.model);
1006
+ // 无链接条目(含历史 done)也参与:先用宿主/本地库回填 sid,再补 usage。
1007
+ if (entry.sessionId && !running && !missing)
1008
+ continue;
1009
+ candidates.push(id);
1010
+ }
1011
+ if (candidates.length > 0) {
1012
+ const start = enrichCursor % candidates.length;
1013
+ let budget = 4;
1014
+ for (let k = 0; k < candidates.length && budget > 0; k++) {
1015
+ const id = candidates[(start + k) % candidates.length];
1016
+ let entry = next.get(id);
1017
+ if (!entry)
1018
+ continue;
1019
+ if (!entry.sessionId) {
1020
+ try {
1021
+ const callId = String(entry.id).replace(/^tool:/, "");
1022
+ const sid = props.api.session.resolveChild?.({
1023
+ parentId: props.sessionId, callId, agent: entry.agent, startedAt: entry.startedAt,
1024
+ });
1025
+ if (sid) {
1026
+ entry = { ...entry, sessionId: sid };
1027
+ next.set(id, entry);
1028
+ changed = true;
1029
+ }
1030
+ }
1031
+ catch { }
1032
+ }
1033
+ if (!entry.sessionId)
1034
+ continue;
1035
+ const childSid = entry.sessionId;
1036
+ // 只从子会话读取,绝不读父会话。较旧的子会话
1037
+ // 可能不在本地缓存中,此时它们仍属于
1038
+ // 本面板的 map,读取是安全的。
666
1039
  let isChild = false;
667
1040
  try {
668
- const s = props.api.session.get(entry.sessionId);
669
- isChild = s?.parentID === props.sessionId;
1041
+ const s = props.api.session.get(childSid);
1042
+ isChild = !s || s.parentID === props.sessionId;
1043
+ }
1044
+ catch {
1045
+ isChild = true;
670
1046
  }
671
- catch { }
672
1047
  if (!isChild)
673
1048
  continue;
674
- const total = props.api.usage.readSessionTokens(entry.sessionId);
675
- const todo = props.api.usage.readSessionTodo(entry.sessionId);
676
- const model = entry.model ?? props.api.usage.readSessionModel(entry.sessionId);
1049
+ budget--;
1050
+ attempted++;
1051
+ const running = entry.status === "running" || entry.status === "cancel_requested";
1052
+ let entryChanged = false;
677
1053
  const nextEntry = { ...entry };
678
- if (total !== undefined && total !== entry.tokens) {
679
- nextEntry.tokens = total;
680
- changed = true;
1054
+ if (running || entry.tokens === undefined) {
1055
+ const total = props.api.usage.readSessionTokens(childSid);
1056
+ if (total !== undefined && total !== entry.tokens) {
1057
+ nextEntry.tokens = total;
1058
+ entryChanged = true;
1059
+ }
1060
+ }
1061
+ if (running || entry.cost === undefined) {
1062
+ const cost = props.api.usage.readSessionCost(childSid);
1063
+ if (cost !== undefined && cost !== entry.cost) {
1064
+ nextEntry.cost = cost;
1065
+ entryChanged = true;
1066
+ }
681
1067
  }
682
- if (todo !== undefined) {
683
- if (todo.total !== entry.todoTotal || todo.done !== entry.todoDone) {
1068
+ if (running || !validModel(entry.model)) {
1069
+ const model = props.api.usage.readSessionModel(childSid);
1070
+ if (model && model !== entry.model) {
1071
+ nextEntry.model = model;
1072
+ entryChanged = true;
1073
+ }
1074
+ }
1075
+ if (running) {
1076
+ const todo = props.api.usage.readSessionTodo(childSid);
1077
+ if (todo !== undefined && (todo.total !== entry.todoTotal || todo.done !== entry.todoDone)) {
684
1078
  nextEntry.todoTotal = todo.total;
685
1079
  nextEntry.todoDone = todo.done;
686
- changed = true;
1080
+ entryChanged = true;
687
1081
  }
688
1082
  }
689
- if (model && !entry.model) {
690
- nextEntry.model = model;
1083
+ if (entryChanged) {
1084
+ next.set(id, nextEntry);
691
1085
  changed = true;
692
1086
  }
693
- if (changed)
694
- next.set(id, nextEntry);
695
1087
  }
1088
+ enrichCursor = (start + Math.max(1, attempted)) % candidates.length;
696
1089
  }
697
- return changed ? next : prev;
1090
+ if (!changed)
1091
+ return prev;
1092
+ enriched = next;
1093
+ return next;
698
1094
  });
1095
+ if (enriched) {
1096
+ // usage enrichment 是真实状态变更(cost/tokens/model)——通过与事件
1097
+ // 处理器相同的 merge-safe 路径持久化。
1098
+ globalEntryCache.set(props.sessionId, new Map(enriched));
1099
+ persistEntries(props.sessionId, enriched);
1100
+ }
699
1101
  });
1102
+ reconcileIdleEntries();
1103
+ // discovery poll(约每 2s):恢复从未通过宿主事件到达的
1104
+ // 子代理 part。与事件订阅的健康状况无关。
1105
+ if (tick % 4 === 0) {
1106
+ untrack(() => {
1107
+ if (missingNewestToolPart()) {
1108
+ runSessionScan(props.sessionId, false);
1109
+ bump();
1110
+ }
1111
+ });
1112
+ }
1113
+ // 周期性对账(约每 30s):存在 running 条目时与历史重扫一次,
1114
+ // 兜底丢失的 success / idle 事件(含消息同步后才可见的 part)。
1115
+ if (tick % 60 === 0) {
1116
+ untrack(() => {
1117
+ const hasRunning = entryList().some((e) => e.status === "running" || e.status === "cancel_requested");
1118
+ if (hasRunning) {
1119
+ runSessionScan(props.sessionId, false);
1120
+ bump();
1121
+ }
1122
+ });
1123
+ }
700
1124
  bump();
701
1125
  }, 500);
702
1126
  bump();
@@ -704,7 +1128,7 @@ export function SubAgentPanel(props) {
704
1128
  handlePartUpdated(e);
705
1129
  bump();
706
1130
  });
707
- const unsubMsg = props.api.event.on("message.updated", () => bump());
1131
+ const unsubMsg = props.api.event.on("message.updated", () => { bump(); scheduleRescan(); });
708
1132
  const unsubIdle = props.api.event.on("session.idle", (e) => {
709
1133
  handleSessionEnd(e, "done");
710
1134
  bump();
@@ -715,6 +1139,7 @@ export function SubAgentPanel(props) {
715
1139
  });
716
1140
  onCleanup(() => {
717
1141
  disposed = true;
1142
+ clearTimeout(rescanTimer);
718
1143
  clearInterval(clock);
719
1144
  clearInterval(tokenTimer);
720
1145
  unsubPart();
@@ -735,7 +1160,9 @@ export function SubAgentPanel(props) {
735
1160
  const tick = clearTick(); // 外部触发清除时 +1,effect 重跑
736
1161
  const forceReload = tick !== lastTick && !switched;
737
1162
  lastTick = tick;
738
- const t = setTimeout(() => {
1163
+ let attempts = 0;
1164
+ let retryTimer;
1165
+ const run = () => {
739
1166
  untrack(() => {
740
1167
  if (switched) {
741
1168
  const { parentSid, isChild } = resolveParent(sid);
@@ -746,155 +1173,29 @@ export function SubAgentPanel(props) {
746
1173
  setScrollOffset(saved);
747
1174
  // 刷新父会话的访问时间 TTL,防止活跃会话的数据过期
748
1175
  if (!isChild && data[sid]?.entries?.length) {
749
- data[sid].ts = Date.now();
750
- saveSessionData(data);
1176
+ void Promise.resolve(updateSessionData(props.api.kv, (latest) => {
1177
+ if (latest[sid]?.entries?.length)
1178
+ latest[sid].ts = Date.now();
1179
+ })).catch(() => { });
751
1180
  }
752
1181
  }
753
- // scan uses setEntryMapRaw — ephemeral data, not persisted to kv.
754
- // Only event-driven changes (handlePartUpdated, handleSessionEnd) persist.
755
- setEntryMapRaw((prev) => {
756
- // 优先从模块级缓存加载,KV 仅作缓存未命中时的回退
757
- const next = (switched || forceReload)
758
- ? new Map(globalEntryCache.get(sid) ?? loadEntries(sid))
759
- : new Map(prev);
760
- // 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目
761
- const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid);
762
- const scanRec = loadSessionData()[scanPSid];
763
- const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds);
764
- try {
765
- const msgs = props.api.session.messages(sid);
766
- if (msgs && msgs.length) {
767
- for (const msg of msgs) {
768
- const parts = props.api.session.part(msg.id) ?? [];
769
- for (const partRaw of parts) {
770
- const part = partRaw;
771
- // Subtask entries are purely event-driven — never created by scan.
772
- // (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
773
- if (part.type === "tool") {
774
- const tool = String(part.tool ?? "");
775
- if (!SUBAGENT_TOOLS.has(tool))
776
- continue;
777
- const id = `tool:${String(part.id ?? "")}`;
778
- if (!part.id)
779
- continue;
780
- const st = part.state;
781
- const rawStatus = String(st?.status ?? "");
782
- const scanStMeta = st?.metadata;
783
- const scanSubSid = scanStMeta?.session_id !== undefined ? String(scanStMeta.session_id)
784
- : scanStMeta?.sessionId !== undefined ? String(scanStMeta.sessionId)
785
- : undefined;
786
- const existingKey = findSubEntryKey(next, id, scanSubSid);
787
- const exists = existingKey ? next.get(existingKey) : undefined;
788
- // 已手动清除的条目:scan 发现但不在内存 → 跳过重建
789
- if (!exists && clearedIds.has(id))
790
- continue;
791
- // Only create entries for tool calls that entered execution.
792
- // "pending" / empty: skip new entries; allow heuristics for existing ones below.
793
- if ((rawStatus === "pending" || rawStatus === "") && !exists)
794
- continue;
795
- // "error": only update existing, never create a new entry
796
- if (rawStatus === "error") {
797
- if (exists && exists.status === "running") {
798
- next.set(existingKey ?? id, { ...exists, status: "error", endedAt: Date.now() });
799
- }
800
- continue;
801
- }
802
- let status = "running";
803
- if (rawStatus === "completed")
804
- status = "done";
805
- // Background tasks: tool completion ≠ agent completion — keep running until session.idle
806
- // Only keep running if state metadata confirms a child session was spawned.
807
- if ((st?.input?.run_in_background === true || st?.input?.background === true) && status === "done") {
808
- const scanStMeta = st?.metadata;
809
- const scanHasChild = scanStMeta?.session_id !== undefined || scanStMeta?.sessionId !== undefined;
810
- if (scanHasChild)
811
- status = "running";
812
- }
813
- // Already settled → skip
814
- if (exists && existingKey === id && exists.status !== "running" && exists.status !== "cancel_requested")
815
- continue;
816
- // Running entry with no explicit status improvement from part:
817
- // try message-level heuristics first, then time-based fallback.
818
- if (exists && status === "running") {
819
- if (!rawStatus) {
820
- const msgTokens = msg?.tokens;
821
- if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
822
- status = "done"; // LLM returned tokens → agent completed
823
- }
824
- else if (Date.now() - exists.startedAt > 30 * 60 * 1000) {
825
- status = "done"; // >30 min idle → assume completed
826
- }
827
- else {
828
- continue;
829
- }
830
- }
831
- else {
832
- continue;
833
- }
834
- }
835
- // If already tracked as running but tool state says completed/error → update
836
- // If not tracked → add fresh
837
- const input = st?.input;
838
- const agent = String(part.subagent_type ?? input?.agent ?? input?.subagent_type ?? tool);
839
- const prompt = String(input?.prompt ?? part.description ?? "");
840
- const desc = input?.description !== undefined ? String(input.description) : "";
841
- const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40);
842
- let tokens;
843
- if (scanSubSid)
844
- tokens = props.api.usage.readSessionTokens(scanSubSid);
845
- const ended = status === "done"; // "error" handled above, never reaches here
846
- const entryKey = existingKey ?? id;
847
- next.set(entryKey, {
848
- id: exists?.id ?? id, title, agent, prompt,
849
- // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
850
- tokens: exists?.tokens ?? tokens,
851
- sessionId: exists?.sessionId ?? scanSubSid,
852
- status,
853
- startedAt: exists?.startedAt || Date.now(),
854
- endedAt: ended ? (exists?.endedAt || Date.now()) : undefined,
855
- });
856
- if (entryKey !== id)
857
- next.delete(id);
858
- }
859
- }
860
- }
861
- }
862
- }
863
- catch { }
864
- return next;
865
- });
866
- // Reconcile: check running entries against live child session status.
867
- // Covers session.idle events missed while user was inside a child session.
868
- setEntryMapRaw((prev) => {
869
- let changed = false;
870
- const next = new Map(prev);
871
- for (const [id, entry] of next) {
872
- if ((entry.status !== "running" && entry.status !== "cancel_requested") || !entry.sessionId)
873
- continue;
874
- try {
875
- const st = props.api.session.status(entry.sessionId);
876
- if (!st || st.type !== "idle")
877
- continue;
878
- const tokens = props.api.usage.readSessionTokens(entry.sessionId);
879
- const cost = props.api.usage.readSessionCost(entry.sessionId);
880
- const finalStatus = entry.status === "cancel_requested" && entry.abortAccepted
881
- ? "cancelled"
882
- : "done";
883
- next.set(id, {
884
- ...entry, status: finalStatus, endedAt: Date.now(),
885
- tokens: tokens ?? entry.tokens,
886
- cost: cost ?? entry.cost,
887
- });
888
- changed = true;
889
- }
890
- catch { }
891
- }
892
- return changed ? next : prev;
893
- });
894
- bump();
1182
+ const ok = runSessionScan(sid, switched || forceReload);
1183
+ // 首次挂载时宿主消息缓存可能尚未就绪;
1184
+ // 重试几次,避免面板一直空着直到
1185
+ // 下一次切换会话。
1186
+ if (!ok && attempts < 4) {
1187
+ attempts++;
1188
+ retryTimer = setTimeout(run, 400);
1189
+ }
895
1190
  });
896
- }, 150);
897
- onCleanup(() => clearTimeout(t));
1191
+ bump();
1192
+ };
1193
+ const t = setTimeout(run, 150);
1194
+ onCleanup(() => {
1195
+ clearTimeout(t);
1196
+ if (retryTimer)
1197
+ clearTimeout(retryTimer);
1198
+ });
898
1199
  });
899
1200
  // ── palette ──
900
1201
  const pal = createMemo(() => {
@@ -1166,11 +1467,14 @@ export function SubAgentPanel(props) {
1166
1467
  };
1167
1468
  const timeColor = () => isActiveRunning ? pal().warning : isError ? pal().error : pal().muted;
1168
1469
  // Entry label: collapsed shows title only, expanded shows title only too
1169
- const tokenText = () => !isExpanded() && entry.tokens !== undefined && entry.tokens > 0
1470
+ const tokenText = () => !isExpanded() && props.showEntryTokens() && entry.tokens !== undefined && entry.tokens > 0
1170
1471
  ? ` ${fmtTokens(entry.tokens)}`
1171
1472
  : "";
1172
- const timeText = () => !isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined)
1173
- ? fmtDurationShort(elapsed(), isActiveRunning)
1473
+ const costText = () => !isExpanded() && props.showEntryCost() && entry.cost !== undefined && entry.cost > 0
1474
+ ? ` $${entry.cost.toFixed(4)}`
1475
+ : "";
1476
+ const timeText = () => !isExpanded() && props.showEntryTime() && (elapsed() >= 2000 || entry.endedAt !== undefined)
1477
+ ? fmtDuration(elapsed(), isActiveRunning, props.timeFormat())
1174
1478
  : "";
1175
1479
  const suffixW = () => {
1176
1480
  let w = 0;
@@ -1180,6 +1484,9 @@ export function SubAgentPanel(props) {
1180
1484
  const tk = tokenText();
1181
1485
  if (tk)
1182
1486
  w += visualWidth(tk);
1487
+ const c = costText();
1488
+ if (c)
1489
+ w += visualWidth(c);
1183
1490
  return w;
1184
1491
  };
1185
1492
  const labelAvail = () => Math.max(6, panelWidth() - gutter() - LEFT_PAD - suffixW());
@@ -1190,10 +1497,10 @@ export function SubAgentPanel(props) {
1190
1497
  const pad = Math.max(0, max - visualWidth(truncated));
1191
1498
  return truncated + " ".repeat(pad);
1192
1499
  };
1193
- return (_jsxs(_Fragment, { children: [_jsxs("text", { onMouseUp: () => toggleExpand(entry.id), children: [_jsx("span", { style: { fg: pal().muted }, children: isExpanded() ? "\u25bc" : "\u25b6" }), " ", _jsx("span", { style: { fg: statusColor() }, children: statusDot() }), " ", _jsx("span", { style: { fg: pal().text }, children: labelText() }), timeText() ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { style: { fg: timeColor() }, children: timeText() })] })) : null, tokenText() ? (_jsx("span", { style: { fg: pal().muted }, children: tokenText() })) : null] }), _jsxs(Show, { when: isExpanded(), children: [_jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("agent.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("agent.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: entry.agent })] }), _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("status.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("status.label"))) }), _jsx("span", { style: { fg: isActiveRunning ? pal().warning : isCancelled ? pal().muted : isError ? pal().error : pal().success }, children: isActiveRunning ? t("status.running") : isCancelled ? t("status.cancelled") : isError ? t("status.error") : t("status.done") })] }), _jsx(Show, { when: elapsed() >= 2000 || entry.endedAt !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("time.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("time.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: fmtDurationShort(elapsed(), isActiveRunning) })] }) }), _jsx(Show, { when: entry.tokens !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("tokens.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("tokens.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: fmtTokens(entry.tokens) })] }) }), _jsx(Show, { when: entry.error, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().error }, children: [t("error.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("error.label"))) }), _jsx("span", { style: { fg: pal().error }, children: truncate(String(entry.error), expandedValAvail()) })] }) }), _jsx(Show, { when: entry.cost !== undefined, children: (() => {
1500
+ return (_jsxs(_Fragment, { children: [_jsxs("text", { onMouseUp: () => toggleExpand(entry.id), children: [_jsx("span", { style: { fg: pal().muted }, children: isExpanded() ? "\u25bc" : "\u25b6" }), " ", _jsx("span", { style: { fg: statusColor() }, children: statusDot() }), " ", _jsx("span", { style: { fg: pal().text }, children: labelText() }), timeText() ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { style: { fg: timeColor() }, children: timeText() })] })) : null, tokenText() ? (_jsx("span", { style: { fg: pal().muted }, children: tokenText() })) : null, costText() ? (_jsx("span", { style: { fg: pal().warning }, children: costText() })) : null] }), _jsxs(Show, { when: isExpanded(), children: [_jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("agent.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("agent.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: entry.agent })] }), _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("status.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("status.label"))) }), _jsx("span", { style: { fg: isActiveRunning ? pal().warning : isCancelled ? pal().muted : isError ? pal().error : pal().success }, children: isActiveRunning ? t("status.running") : isCancelled ? t("status.cancelled") : isError ? t("status.error") : t("status.done") })] }), _jsx(Show, { when: elapsed() >= 2000 || entry.endedAt !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("time.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("time.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: fmtDuration(elapsed(), isActiveRunning, props.timeFormat()) })] }) }), _jsx(Show, { when: entry.tokens !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("tokens.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("tokens.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: fmtTokens(entry.tokens) })] }) }), _jsx(Show, { when: entry.error, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().error }, children: [t("error.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("error.label"))) }), _jsx("span", { style: { fg: pal().error }, children: truncate(String(entry.error), expandedValAvail()) })] }) }), _jsx(Show, { when: entry.cost !== undefined, children: (() => {
1194
1501
  const cost = entry.cost;
1195
1502
  return (_jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("cost.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("cost.label"))) }), _jsxs("span", { style: { fg: pal().muted }, children: ["$", cost.toFixed(4)] })] }));
1196
- })() }), _jsx(Show, { when: entry.model, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("model.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("model.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: truncate(entry.model, expandedValAvail()) })] }) }), _jsx(Show, { when: entry.todoTotal !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("todo.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("todo.label"))) }), _jsxs("span", { style: { fg: pal().muted }, children: [entry.todoDone, "/", entry.todoTotal] })] }) }), _jsx(Show, { when: entry.sessionId, children: _jsxs("text", { onMouseUp: async () => {
1503
+ })() }), _jsx(Show, { when: validModel(entry.model) ? entry.model : undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("model.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("model.label"))) }), _jsx("span", { style: { fg: pal().muted }, children: truncate(entry.model, expandedValAvail()) })] }) }), _jsx(Show, { when: entry.todoTotal !== undefined, children: _jsxs("text", { children: [" ", _jsxs("span", { style: { fg: pal().primary }, children: [t("todo.label"), ": "] }), _jsx("span", { style: { fg: pal().muted }, children: " ".repeat(expandedPad(t("todo.label"))) }), _jsxs("span", { style: { fg: pal().muted }, children: [entry.todoDone, "/", entry.todoTotal] })] }) }), _jsx(Show, { when: entry.sessionId, children: _jsxs("text", { onMouseUp: async () => {
1197
1504
  const sessionId = entry.sessionId;
1198
1505
  if (!sessionId)
1199
1506
  return;