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