opencode-subagent-magazine 1.7.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-subagent-magazine",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "OpenCode TUI plugin monitoring sub-agent invocation status in real time",
5
5
  "type": "module",
6
6
  "types": "dist/index.d.ts",
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.7.0";
2
+ export const PLUGIN_VERSION="1.7.1";
package/src/core/kv.ts CHANGED
@@ -29,6 +29,7 @@ export const SETTING_KEYS = {
29
29
  showEntryTime: `${KV_PREFIX}.show_entry_time`,
30
30
  showEntryTokens: `${KV_PREFIX}.show_entry_tokens`,
31
31
  timeFormat: `${KV_PREFIX}.time_format`,
32
+ showOrigin: `${KV_PREFIX}.show_origin`,
32
33
  dbSync: `${KV_PREFIX}.db_sync`,
33
34
  } as const
34
35
 
package/src/core/types.ts CHANGED
@@ -20,6 +20,10 @@ export interface SubEntry {
20
20
  cancelRequestedAt?: number
21
21
  abortAccepted?: boolean
22
22
  cancelReason?: "manual"
23
+ /** 来源:工具调用条目 / 衍生会话条目(spawn)。 */
24
+ origin?: "tool" | "sub"
25
+ /** 工具输入带 background/run_in_background(后台衍生会话)。 */
26
+ background?: boolean
23
27
  }
24
28
 
25
29
  export type Lang = LangCode
@@ -31,6 +35,13 @@ export type TimeFormat = "decimal" | "short" | "clock" | "compact" | "seconds"
31
35
  /** OpenCode built-in tool names that spawn sub-agents or delegate tasks. */
32
36
  export const SUBAGENT_TOOLS = new Set(["task", "subagent", "delegate", "call_omo_agent"])
33
37
 
38
+ /** 工具输入是否声明后台执行(V1/V2 两种字段名)。 */
39
+ export function isBackgroundInput(input: unknown): boolean {
40
+ if (!input || typeof input !== "object") return false
41
+ const i = input as Record<string, unknown>
42
+ return i.background === true || i.run_in_background === true
43
+ }
44
+
34
45
  export interface ChildRecord {
35
46
  scroll: number
36
47
  expanded: string
@@ -66,6 +77,8 @@ export interface SharedSignals {
66
77
  setShowEntryTokens: (v: boolean) => void
67
78
  timeFormat: () => TimeFormat
68
79
  setTimeFormat: (f: TimeFormat) => void
80
+ showOrigin: () => boolean
81
+ setShowOrigin: (v: boolean) => void
69
82
  dbSync: () => boolean
70
83
  setDbSync: (v: boolean) => void
71
84
  sessionId: string
package/src/i18n.ts CHANGED
@@ -10,6 +10,10 @@ const ZH_T = {
10
10
  "panel.title": "子代理",
11
11
  "status.none": "暂无子代理",
12
12
  "agent.label": "代理",
13
+ "origin.label": "来源",
14
+ "origin.tool": "子代理调用",
15
+ "origin.sub": "衍生会话",
16
+ "origin.background": "后台",
13
17
  "status.label": "状态",
14
18
  "time.label": "耗时",
15
19
  "tokens.label": "上下文",
@@ -71,6 +75,7 @@ const ZH_T = {
71
75
  "settings.showEntryTime": "列表中显示耗时",
72
76
  "settings.showEntryTokens": "列表中显示 Token 数",
73
77
  "settings.timeFormat": "时间格式",
78
+ "settings.showOrigin": "列表显示来源标记",
74
79
  "settings.dbSync": "本地数据库补全",
75
80
  "settings.on": "开",
76
81
  "settings.off": "关",
@@ -86,6 +91,10 @@ const EN_T: Translation = {
86
91
  "panel.title": "SubAgent",
87
92
  "status.none": "No sub-agents yet",
88
93
  "agent.label": "agent",
94
+ "origin.label": "origin",
95
+ "origin.tool": "sub-agent call",
96
+ "origin.sub": "spawned session",
97
+ "origin.background": "background",
89
98
  "status.label": "status",
90
99
  "time.label": "time",
91
100
  "tokens.label": "tokens",
@@ -146,6 +155,7 @@ const EN_T: Translation = {
146
155
  "settings.showEntryCost": "Cost per sub-agent",
147
156
  "settings.showEntryTime": "Elapsed time in list",
148
157
  "settings.showEntryTokens": "Token count in list",
158
+ "settings.showOrigin": "Origin indicators in list",
149
159
  "settings.timeFormat": "Time format",
150
160
  "settings.dbSync": "Local database enrichment",
151
161
  "settings.on": "on",
@@ -159,6 +169,10 @@ const JA_T: Translation = {
159
169
  "panel.title": "サブエージェント",
160
170
  "status.none": "サブエージェントなし",
161
171
  "agent.label": "エージェント",
172
+ "origin.label": "発生元",
173
+ "origin.tool": "サブエージェント呼び出し",
174
+ "origin.sub": "生成されたセッション",
175
+ "origin.background": "バックグラウンド",
162
176
  "status.label": "状態",
163
177
  "time.label": "時間",
164
178
  "tokens.label": "コンテキスト",
@@ -219,6 +233,7 @@ const JA_T: Translation = {
219
233
  "settings.showEntryCost": "サブエージェント毎の費用",
220
234
  "settings.showEntryTime": "一覧に所要時間を表示",
221
235
  "settings.showEntryTokens": "一覧にトークン数を表示",
236
+ "settings.showOrigin": "一覧に由来マークを表示",
222
237
  "settings.timeFormat": "時間形式",
223
238
  "settings.dbSync": "ローカルDB補完",
224
239
  "settings.on": "オン",
@@ -232,6 +247,10 @@ const KO_T: Translation = {
232
247
  "panel.title": "서브 에이전트",
233
248
  "status.none": "서브 에이전트 없음",
234
249
  "agent.label": "에이전트",
250
+ "origin.label": "출처",
251
+ "origin.tool": "하위 에이전트 호출",
252
+ "origin.sub": "생성된 세션",
253
+ "origin.background": "백그라운드",
235
254
  "status.label": "상태",
236
255
  "time.label": "시간",
237
256
  "tokens.label": "컨텍스트",
@@ -292,6 +311,7 @@ const KO_T: Translation = {
292
311
  "settings.showEntryCost": "하위 에이전트별 비용",
293
312
  "settings.showEntryTime": "목록에 소요 시간 표시",
294
313
  "settings.showEntryTokens": "목록에 토큰 수 표시",
314
+ "settings.showOrigin": "목록에 출처 아이콘 표시",
295
315
  "settings.timeFormat": "시간 형식",
296
316
  "settings.dbSync": "로컬 DB 보완",
297
317
  "settings.on": "켬",
package/src/index.tsx CHANGED
@@ -40,6 +40,7 @@ function createSidebarSlot(api: TuiPluginApi, panelApi: PanelApi, sig: SharedSig
40
40
  showEntryTime={sig.showEntryTime}
41
41
  showEntryTokens={sig.showEntryTokens}
42
42
  timeFormat={sig.timeFormat}
43
+ showOrigin={sig.showOrigin}
43
44
  sessionId={input.session_id}
44
45
  />
45
46
  )
@@ -79,11 +80,14 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
79
80
  const [timeFormat, setTimeFormat] = createSignal<TimeFormat>(
80
81
  (TIME_FORMATS as readonly string[]).includes(storedTimeFormat) ? (storedTimeFormat as TimeFormat) : "short"
81
82
  )
83
+ const [showOrigin, setShowOrigin] = createSignal<boolean>(
84
+ (api.kv.get(`${KV_PREFIX}.show_origin`, false) as boolean) === true
85
+ )
82
86
  const [dbSync, setDbSync] = createSignal<boolean>(
83
87
  (api.kv.get(`${KV_PREFIX}.db_sync`, true) as boolean) !== false
84
88
  )
85
89
 
86
- const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible, setBorderVisible, showEntryCost, setShowEntryCost, showEntryTime, setShowEntryTime, showEntryTokens, setShowEntryTokens, timeFormat, setTimeFormat, dbSync, setDbSync, sessionId: "" }
90
+ const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible, setBorderVisible, showEntryCost, setShowEntryCost, showEntryTime, setShowEntryTime, showEntryTokens, setShowEntryTokens, timeFormat, setTimeFormat, showOrigin, setShowOrigin, dbSync, setDbSync, sessionId: "" }
87
91
 
88
92
  // ── V1 PanelApi adapter: wraps the V1 host API into the shared panel contract ──
89
93
  const v1Api: PanelApi = {
@@ -553,6 +557,22 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
553
557
  ))
554
558
  },
555
559
  },
560
+ {
561
+ title: "SubAgent Magazine: Origin Indicators",
562
+ value: "subagent-origin",
563
+ description: "Show or hide origin markers (⇢/↳) in the sidebar list",
564
+ slash: { name: "subagent-origin" },
565
+ onSelect: (dialog) => {
566
+ const t = createT(() => signals.lang())
567
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.show_origin`, false))
568
+ api.kv.set(`${KV_PREFIX}.show_origin`, !cur)
569
+ signals.setShowOrigin(!cur)
570
+ api.ui.toast({
571
+ message: `${t("settings.showOrigin")}: ${!cur ? t("settings.on") : t("settings.off")}`,
572
+ })
573
+ dialog?.clear()
574
+ },
575
+ },
556
576
  ])
557
577
  }
558
578
 
@@ -15,7 +15,7 @@ import { PLUGIN_VERSION } from "../_version"
15
15
  import { copyText } from "../clipboard"
16
16
  import { createT } from "../i18n"
17
17
  import type { Lang, SortOrder, ScrollMode, SubEntry, SubStatus, SessionRecord, TimeFormat } from "../core/types"
18
- import { SUBAGENT_TOOLS } from "../core/types"
18
+ import { SUBAGENT_TOOLS, isBackgroundInput } from "../core/types"
19
19
  import { visualWidth, truncate, fmtDuration, fmtTokens, safeErrorMsg } from "../core/format"
20
20
  import { rgb, desaturateTo, dimColor, FALLBACK, MAX_SAT } from "../core/color"
21
21
  import { KV_PREFIX, updateSessionData } from "../core/kv"
@@ -63,6 +63,7 @@ export function SubAgentPanel(props: {
63
63
  showEntryTime: () => boolean
64
64
  showEntryTokens: () => boolean
65
65
  timeFormat: () => TimeFormat
66
+ showOrigin: () => boolean
66
67
  sessionId: string
67
68
  }): JSX.Element {
68
69
  const t = createT(() => props.lang())
@@ -417,7 +418,7 @@ export function SubAgentPanel(props: {
417
418
  const subSid = part.sessionID !== undefined ? String(part.sessionID) : undefined
418
419
  const partModel = part.model as { modelID?: string } | undefined
419
420
  const modelId = partModel?.modelID ? String(partModel.modelID) : undefined
420
- 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" })
421
422
  }
422
423
 
423
424
  // ToolPart
@@ -451,6 +452,8 @@ export function SubAgentPanel(props: {
451
452
  prompt: existing.prompt,
452
453
  sessionId: existing.sessionId ?? errorSid,
453
454
  status: "error",
455
+ origin: "tool",
456
+ background: isBackgroundInput(st?.input),
454
457
  })
455
458
  }
456
459
  return
@@ -463,7 +466,7 @@ export function SubAgentPanel(props: {
463
466
  // Background tasks: tool completion ≠ agent completion — keep running until session.idle
464
467
  // Only keep running if state metadata confirms a child session was spawned;
465
468
  // otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever.
466
- if ((input?.run_in_background === true || input?.background === true) && status === "done") {
469
+ if (isBackgroundInput(input) && status === "done") {
467
470
  const stMetaCheck = partMetadata(part as Record<string, any>, st)
468
471
  const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined
469
472
  if (hasChild) status = "running"
@@ -481,7 +484,7 @@ export function SubAgentPanel(props: {
481
484
  const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
482
485
  : stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
483
486
  : undefined
484
- upsertEntry({ id, title, agent, prompt, sessionId: subSid, status })
487
+ upsertEntry({ id, title, agent, prompt, sessionId: subSid, status, origin: "tool", background: isBackgroundInput(input) })
485
488
  }
486
489
  }
487
490
 
@@ -869,7 +872,7 @@ export function SubAgentPanel(props: {
869
872
  if (rawStatus === "completed") status = "done"
870
873
  // Background tasks: tool completion ≠ agent completion — keep running until session.idle
871
874
  // 仅在 metadata 确认已派生子会话时保持 running。
872
- if ((scanInput?.run_in_background === true || scanInput?.background === true) && status === "done") {
875
+ if (isBackgroundInput(scanInput) && status === "done") {
873
876
  if (scanSubSid !== undefined) status = "running"
874
877
  }
875
878
  // 子会话存活判定:工具调用已完成但子会话仍 busy,
@@ -930,6 +933,8 @@ export function SubAgentPanel(props: {
930
933
  status,
931
934
  startedAt: exists?.startedAt || Date.now(),
932
935
  endedAt: exists?.endedAt ?? (status === "done" ? Date.now() : undefined),
936
+ origin: "tool",
937
+ background: isBackgroundInput(scanInput) || exists?.background === true,
933
938
  })
934
939
  if (entryKey !== id) next.delete(id)
935
940
  }
@@ -987,7 +992,7 @@ export function SubAgentPanel(props: {
987
992
  // 非后台 tool 已完成、条目却仍停在 running:补一次 scan 收尾
988
993
  // (success 事件可能因缺少 metadata 被适配层丢弃)。
989
994
  const input = st?.input as Record<string, unknown> | undefined
990
- const isBackground = input?.background === true || input?.run_in_background === true
995
+ const isBackground = isBackgroundInput(input)
991
996
  const stillRunning = entry.status === "running" || entry.status === "cancel_requested"
992
997
  if (rawStatus === "completed" && !isBackground && stillRunning) return true
993
998
  }
@@ -1027,6 +1032,57 @@ export function SubAgentPanel(props: {
1027
1032
  setEntryMapRaw((prev) => {
1028
1033
  let changed = false
1029
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
+ }
1030
1086
  // 候选:仍缺 usage 数据的条目。以 round-robin 遍历,
1031
1087
  // 避免一串读不到数据的会话(如 free-model 子会话)
1032
1088
  // 饿死列表其余部分;每个 tick 只读取少数会话。
@@ -1535,6 +1591,13 @@ export function SubAgentPanel(props: {
1535
1591
  !isExpanded() && props.showEntryTime() && (elapsed() >= 2000 || entry.endedAt !== undefined)
1536
1592
  ? fmtDuration(elapsed(), isActiveRunning, props.timeFormat())
1537
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)
1538
1601
  const suffixW = () => {
1539
1602
  let w = 0
1540
1603
  const t = timeText()
@@ -1545,7 +1608,7 @@ export function SubAgentPanel(props: {
1545
1608
  if (c) w += visualWidth(c)
1546
1609
  return w
1547
1610
  }
1548
- const labelAvail = () => Math.max(6, panelWidth() - gutter() - LEFT_PAD - suffixW())
1611
+ const labelAvail = () => Math.max(6, panelWidth() - gutter() - LEFT_PAD - originW() - suffixW())
1549
1612
  const labelText = () => {
1550
1613
  const max = labelAvail()
1551
1614
  const text = entry.title || entry.agent
@@ -1564,6 +1627,7 @@ export function SubAgentPanel(props: {
1564
1627
  {" "}
1565
1628
  <span style={{ fg: statusColor() }}>{statusDot()}</span>
1566
1629
  {" "}
1630
+ {originMark() ? <span style={{ fg: pal().muted }}>{originMark() + " "}</span> : null}
1567
1631
  <span style={{ fg: pal().text }}>{labelText()}</span>
1568
1632
  {timeText() ? (
1569
1633
  <>
@@ -1587,6 +1651,17 @@ export function SubAgentPanel(props: {
1587
1651
  <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("agent.label")))}</span>
1588
1652
  <span style={{ fg: pal().muted }}>{entry.agent}</span>
1589
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>
1590
1665
  <text>
1591
1666
  {" "}
1592
1667
  <span style={{ fg: pal().primary }}>{t("status.label")}: </span>
@@ -2,6 +2,7 @@ import type { SubEntry, SubStatus } from "../core/types"
2
2
 
3
3
  export type SubEntryPatch = Omit<SubEntry, "startedAt" | "endedAt"> & {
4
4
  startedAt?: number
5
+ endedAt?: number
5
6
  }
6
7
 
7
8
  export function findSubEntryKey(
@@ -33,7 +34,7 @@ export function upsertSubEntry(
33
34
  ...partial,
34
35
  id: existing?.id ?? partial.id,
35
36
  startedAt: existing?.startedAt || partial.startedAt || now,
36
- endedAt: ended ? (existing?.endedAt || now) : undefined,
37
+ endedAt: ended ? (existing?.endedAt ?? partial.endedAt ?? now) : undefined,
37
38
  })
38
39
  return next
39
40
  }
@@ -10,6 +10,21 @@ export interface SessionLike {
10
10
  cost?: number
11
11
  }
12
12
 
13
+ /** Spawned child session summary (V2 reads it from the local database). */
14
+ export interface ChildSessionLike {
15
+ id: string
16
+ agent?: string
17
+ title?: string
18
+ model?: string
19
+ cost?: number
20
+ tokens?: number
21
+ timeCreated?: number
22
+ timeIdle?: number
23
+ idleOutcome?: string
24
+ /** Still running: last message is newer than time_idle (resume keeps the old value). */
25
+ active?: boolean
26
+ }
27
+
13
28
  export interface SessionStatusLike {
14
29
  type: string
15
30
  }
@@ -59,6 +74,8 @@ export interface PanelApi {
59
74
  agent?: string
60
75
  startedAt?: number
61
76
  }): string | undefined
77
+ /** Optional host-side listing of spawned child sessions. */
78
+ listChildren?(parentId: string): ChildSessionLike[] | undefined
62
79
  }
63
80
  event: {
64
81
  on(type: PanelEventType, cb: (e: PanelEvent) => void): () => void
@@ -125,6 +125,7 @@ export function makeCommands(
125
125
  `${t("settings.showEntryCost")}: ${onOff(signals.showEntryCost())}`,
126
126
  `${t("settings.showEntryTime")}: ${onOff(signals.showEntryTime())}`,
127
127
  `${t("settings.showEntryTokens")}: ${onOff(signals.showEntryTokens())}`,
128
+ `${t("settings.showOrigin")}: ${onOff(signals.showOrigin())}`,
128
129
  `${t("settings.timeFormat")}: ${TIME_FORMAT_SAMPLES[signals.timeFormat()]}`,
129
130
  `${t("settings.dbSync")}: ${onOff(signals.dbSync())}`,
130
131
  ].join("\n")
@@ -198,6 +199,19 @@ export function makeCommands(
198
199
  api.ui.toast(`${t("settings.showEntryTokens")}: ${onOff(v)}`)
199
200
  },
200
201
  },
202
+ {
203
+ id: "opencode-subagent-magazine.subagent.origin",
204
+ title: "SubAgent Magazine: Toggle Origin Indicators",
205
+ description: "Show or hide origin markers (⇢ background / ↳ spawned) in the sidebar list",
206
+ slash: { name: "subagent-origin" },
207
+ palette: true,
208
+ run: () => {
209
+ const v = !signals.showOrigin()
210
+ signals.setShowOrigin(v)
211
+ kv.set(SETTING_KEYS.showOrigin, v)
212
+ api.ui.toast(`${t("settings.showOrigin")}: ${onOff(v)}`)
213
+ },
214
+ },
201
215
  {
202
216
  id: "opencode-subagent-magazine.subagent.db",
203
217
  title: "SubAgent Magazine: Toggle Local DB",
package/src/v2/db.ts CHANGED
@@ -25,6 +25,8 @@ export interface ChildSessionInfo {
25
25
  timeCreated?: number
26
26
  timeIdle?: number
27
27
  idleOutcome?: string
28
+ /** 仍在运行:最后一条消息晚于 time_idle(会话被恢复时 time_idle 不会被清空)。 */
29
+ active?: boolean
28
30
  }
29
31
 
30
32
  export interface SessionDbIndex {
@@ -36,6 +38,8 @@ export interface SessionDbIndex {
36
38
  matchChild(parentId: string, agent: string | undefined, startedAt: number | undefined): string | undefined
37
39
  /** 子会话汇总(model/tokens/cost/time_idle)。 */
38
40
  info(sid: string): ChildSessionInfo | undefined
41
+ /** 该会话派生的子会话(spawn),用于补全没有工具条目的衍生会话。 */
42
+ children(parentId: string): ChildSessionInfo[] | undefined
39
43
  }
40
44
 
41
45
  type SqlRow = Record<string, unknown>
@@ -89,10 +93,28 @@ export function contextTokens(tokens: unknown): number | undefined {
89
93
  return sum > 0 ? sum : undefined
90
94
  }
91
95
 
92
- /** 子会话终态:time_idle 存在才算结束;interrupted 视为取消。
96
+ /** 子会话是否仍在运行。
97
+ * 恢复过的会话会保留旧的 time_idle(host 不清空),因此以
98
+ * 「最后一条消息是否晚于 time_idle」为准;最后一条是 idle
99
+ * 消息则视为已停止。 */
100
+ export function isSessionActive(input: {
101
+ timeIdle?: number
102
+ lastMessageType?: string
103
+ lastMessageAt?: number
104
+ }): boolean {
105
+ const { timeIdle, lastMessageType, lastMessageAt } = input
106
+ if (lastMessageType === "idle") return false
107
+ if (lastMessageAt === undefined) return false
108
+ if (timeIdle === undefined) return true
109
+ return lastMessageAt > timeIdle
110
+ }
111
+
112
+ /** 子会话终态:time_idle 存在且没有恢复活动才算结束;interrupted 视为取消。
93
113
  * 没有 time_idle 时不作判断(交给宿主实时状态)——避免时间阈值猜测。 */
94
114
  export function statusOfChild(info: ChildSessionInfo | undefined): "done" | "cancelled" | undefined {
95
- if (!info || info.timeIdle == null) return undefined
115
+ if (!info) return undefined
116
+ if (info.active === true) return undefined
117
+ if (info.timeIdle == null) return undefined
96
118
  return info.idleOutcome === "interrupted" ? "cancelled" : "done"
97
119
  }
98
120
 
@@ -136,12 +158,21 @@ const LAST_ASSISTANT_SQL = `
136
158
  WHERE session_id = ? AND type = 'assistant' AND json_extract(data, '$.tokens.output') > 0
137
159
  ORDER BY seq DESC LIMIT 1`
138
160
 
161
+ const LAST_MESSAGE_SQL = `
162
+ SELECT type, COALESCE(time_updated, time_created) AS at
163
+ FROM session_message WHERE session_id = ? ORDER BY seq DESC LIMIT 1`
164
+
139
165
  const ASSISTANT_COST_SQL = `
140
166
  SELECT COUNT(*) AS n, SUM(COALESCE(json_extract(data, '$.cost'), 0)) AS total
141
167
  FROM session_message WHERE session_id = ? AND type = 'assistant'`
142
168
 
143
169
  const CHILDREN_SQL = `
144
- SELECT id, agent, time_created FROM session_v2 WHERE parent_id = ? ORDER BY time_created`
170
+ SELECT id, agent, title, model, cost,
171
+ tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
172
+ time_created, time_idle, idle_outcome
173
+ FROM session_v2
174
+ WHERE parent_id = ? AND (fork_session_id IS NULL OR fork_session_id = '')
175
+ ORDER BY time_created`
145
176
 
146
177
  const num = (v: unknown): number | undefined => {
147
178
  const n = Number(v)
@@ -236,6 +267,7 @@ export function createSessionDbIndex(enabled: () => boolean): SessionDbIndex | u
236
267
  const row = db!.query(CHILD_INFO_SQL).get(sid)
237
268
  if (row) {
238
269
  const last = db!.query(LAST_ASSISTANT_SQL).get(sid)
270
+ const lastMsg = db!.query(LAST_MESSAGE_SQL).get(sid)
239
271
  let tokens = contextTokens(last ? {
240
272
  input: last.input, output: last.output, reasoning: last.reasoning,
241
273
  cache: { read: last.cache_read, write: last.cache_write },
@@ -263,6 +295,11 @@ export function createSessionDbIndex(enabled: () => boolean): SessionDbIndex | u
263
295
  timeCreated: num(row.time_created),
264
296
  timeIdle: num(row.time_idle),
265
297
  idleOutcome: str(row.idle_outcome),
298
+ active: isSessionActive({
299
+ timeIdle: num(row.time_idle),
300
+ lastMessageType: str(lastMsg?.type),
301
+ lastMessageAt: num(lastMsg?.at),
302
+ }),
266
303
  }
267
304
  }
268
305
  } catch { result = undefined }
@@ -270,5 +307,40 @@ export function createSessionDbIndex(enabled: () => boolean): SessionDbIndex | u
270
307
  return result
271
308
  }
272
309
 
273
- return { enabled: ready, resolveCall, matchChild, info }
310
+ const children = (parentId: string): ChildSessionInfo[] | undefined => {
311
+ if (!ready() || !parentId) return undefined
312
+ const now = Date.now()
313
+ let cached = childrenCache.get(parentId)
314
+ if (!cached || now - cached.at > CALL_MAP_TTL_MS) {
315
+ let list: SqlRow[] = []
316
+ try { list = db!.query(CHILDREN_SQL).all(parentId) } catch {}
317
+ cached = { list, at: now }
318
+ childrenCache.set(parentId, cached)
319
+ }
320
+ const out: ChildSessionInfo[] = []
321
+ for (const row of cached.list) {
322
+ const sid = str(row.id)
323
+ if (!sid) continue
324
+ const full = info(sid)
325
+ if (full) { out.push(full); continue }
326
+ // info 缓存未命中时不丢字段:用列表行自身的汇总兜底。
327
+ const agg = (num(row.tokens_input) || 0) + (num(row.tokens_output) || 0) + (num(row.tokens_reasoning) || 0) +
328
+ (num(row.tokens_cache_read) || 0) + (num(row.tokens_cache_write) || 0)
329
+ out.push({
330
+ id: sid,
331
+ parentId,
332
+ agent: str(row.agent),
333
+ title: str(row.title),
334
+ model: modelIdOf(row.model),
335
+ cost: num(row.cost),
336
+ tokens: agg > 0 ? agg : undefined,
337
+ timeCreated: num(row.time_created),
338
+ timeIdle: num(row.time_idle),
339
+ idleOutcome: str(row.idle_outcome),
340
+ })
341
+ }
342
+ return out
343
+ }
344
+
345
+ return { enabled: ready, resolveCall, matchChild, info, children }
274
346
  }
package/src/v2/index.tsx CHANGED
@@ -45,6 +45,7 @@ function PluginRoot(props: {
45
45
  showEntryTime={props.signals.showEntryTime}
46
46
  showEntryTokens={props.signals.showEntryTokens}
47
47
  timeFormat={props.signals.timeFormat}
48
+ showOrigin={props.signals.showOrigin}
48
49
  sessionId={props.sessionID}
49
50
  />
50
51
  )
@@ -95,6 +96,9 @@ const mod: PluginModule & { server: () => Promise<Record<string, never>> } = {
95
96
  const [timeFormatSignal, setTimeFormat] = createSignal<TimeFormat>(
96
97
  (TIME_FORMATS as readonly string[]).includes(storedTimeFormat) ? (storedTimeFormat as TimeFormat) : "short",
97
98
  )
99
+ const [showOriginSignal, setShowOrigin] = createSignal<boolean>(
100
+ (api.kv.get(SETTING_KEYS.showOrigin, false) as boolean) === true,
101
+ )
98
102
  const [dbSyncSignal, setDbSync] = createSignal<boolean>(
99
103
  (api.kv.get(SETTING_KEYS.dbSync, true) as boolean) !== false,
100
104
  )
@@ -109,6 +113,7 @@ const mod: PluginModule & { server: () => Promise<Record<string, never>> } = {
109
113
  showEntryTime: showEntryTimeSignal, setShowEntryTime,
110
114
  showEntryTokens: showEntryTokensSignal, setShowEntryTokens,
111
115
  timeFormat: timeFormatSignal, setTimeFormat,
116
+ showOrigin: showOriginSignal, setShowOrigin,
112
117
  dbSync: dbSyncSignal, setDbSync,
113
118
  sessionId: "",
114
119
  }
@@ -49,6 +49,7 @@ async function runSettingsMenu(context: Context, api: PanelApi, signals: SharedS
49
49
  { title: t("settings.showEntryTime"), value: "time", description: onOff(signals.showEntryTime()), category },
50
50
  { title: t("settings.timeFormat"), value: "timefmt", description: TIME_FORMAT_SAMPLES[signals.timeFormat()], category },
51
51
  { title: t("settings.showEntryTokens"), value: "tokens", description: onOff(signals.showEntryTokens()), category },
52
+ { title: t("settings.showOrigin"), value: "origin", description: onOff(signals.showOrigin()), category },
52
53
  { title: t("settings.dbSync"), value: "dbsync", description: onOff(signals.dbSync()), category },
53
54
  { title: t("settings.close"), value: "close" },
54
55
  ],
@@ -218,6 +219,23 @@ async function runSettingsMenu(context: Context, api: PanelApi, signals: SharedS
218
219
  }
219
220
  }
220
221
 
222
+ // ── Origin indicators ──
223
+ if (choice === "origin") {
224
+ const picked = await context.ui.dialog.select<boolean>({
225
+ title: t("settings.showOrigin"),
226
+ options: [
227
+ { title: t("settings.on"), value: true },
228
+ { title: t("settings.off"), value: false },
229
+ ],
230
+ current: signals.showOrigin(),
231
+ })
232
+ if (picked !== undefined) {
233
+ signals.setShowOrigin(picked)
234
+ kv.set(SETTING_KEYS.showOrigin, picked)
235
+ toast(t("settings.showOrigin"), onOff(picked))
236
+ }
237
+ }
238
+
221
239
  // ── 本地数据库补全 ──
222
240
  if (choice === "dbsync") {
223
241
  const picked = await context.ui.dialog.select<boolean>({
@@ -423,9 +423,11 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"])
423
423
  }
424
424
  } catch {}
425
425
  // 宿主没有该子会话的实时状态(未加载缓存)时用本地库终态兜底:
426
- // time_idle 存在即已结束;不存在则不猜测(交给事件/扫描)。
426
+ // time_idle 存在且没有恢复活动即已结束;仍在运行则回报 busy。
427
427
  try {
428
- if (statusOfChild(dbIndex?.info(sid)) !== undefined) return { type: "idle" }
428
+ const info = dbIndex?.info(sid)
429
+ if (info?.active === true) return { type: "busy" }
430
+ if (statusOfChild(info) !== undefined) return { type: "idle" }
429
431
  } catch {}
430
432
  return undefined
431
433
  },
@@ -448,6 +450,9 @@ export function createPanelApi(context: Context, settings: PanelApi["settings"])
448
450
  return dbIndex?.matchChild(input.parentId, input.agent, input.startedAt)
449
451
  } catch { return undefined }
450
452
  },
453
+ listChildren: (parentId) => {
454
+ try { return dbIndex?.enabled() ? dbIndex.children(parentId) : undefined } catch { return undefined }
455
+ },
451
456
  },
452
457
  event: {
453
458
  on: (type: PanelEventType, cb: (e: PanelEvent) => void) => {