@dijkspicy/opencode-stats-reporter 0.1.3 → 0.1.5

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.
@@ -65,7 +65,7 @@ config.json 不存在、JSON 非法、字段缺失或 URL 非法时一律静默
65
65
  {
66
66
  "schema_version": 1,
67
67
  "install_id": "0b3f7e6a-....",
68
- "reporter": { "client": "opencode", "version": "0.1.3" },
68
+ "reporter": { "client": "opencode", "version": "0.1.5" },
69
69
  "sessions": [
70
70
  {
71
71
  "session_id": "ses_...",
@@ -81,13 +81,15 @@ config.json 不存在、JSON 非法、字段缺失或 URL 非法时一律静默
81
81
  "session_created_at": 1759000000000,
82
82
  "session_title": "每30s自动刷新功能",
83
83
  "session_directory": "/opt/projects/BlueRegionUsage",
84
+ "parent_session_id": "ses_...",
85
+ "root_session_id": "ses_...",
84
86
  "last_activity_at": 1759003600123
85
87
  }
86
88
  ]
87
89
  }
88
90
  ```
89
91
 
90
- `model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null`)。`session_title` / `session_directory` 取自**同一次** `session.get` 调用(不新增 API 请求),取不到时为 `null`(不是空字符串)。载荷中**不存在** consumer/identity 类字段。
92
+ `model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null`)。`session_title` / `session_directory` / `parent_session_id`(取自 `session.get` 的 `parentID`)复用**同一次** `session.get` 调用(不新增 API 请求),取不到时为 `null`(不是空字符串)。`root_session_id` 为沿 `parentID` 回溯到的顶层祖先(深度上限 20、防环;祖先已删除/报错时以最近一次验证成功的会话为根;自身即顶层时等于 `session_id`)。载荷中**不存在** consumer/identity 类字段。
91
93
 
92
94
  ## 测试
93
95
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-reporter",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "opencode 客户端插件:在会话空闲时聚合各会话(含子代理会话)的 token 用量快照,经有界队列 + 指数退避非阻塞上报;载荷不含消费者身份字段。",
@@ -78,7 +78,7 @@ const makeClient = (
78
78
  messages: Record<string, UsageMessage[]>,
79
79
  children: Record<string, string[]> = {},
80
80
  /** session.get 的返回元数据;不传则 client 不含 session.get */
81
- meta?: { created?: number | null; title?: string | null; directory?: string | null; throws?: boolean },
81
+ meta?: { created?: number | null; title?: string | null; directory?: string | null; parentID?: string | null; throws?: boolean },
82
82
  ): ReporterClient => ({
83
83
  session: {
84
84
  messages: async (args: { path: { id: string } }) => ({ data: messages[args.path.id] ?? [] }),
@@ -92,7 +92,12 @@ const makeClient = (
92
92
  throw new Error("boom: session.get failed")
93
93
  }
94
94
  : async () => ({
95
- data: { time: { created: meta.created ?? 1_700_000_000_000 }, title: meta.title ?? null, directory: meta.directory ?? null },
95
+ data: {
96
+ time: { created: meta.created ?? 1_700_000_000_000 },
97
+ title: meta.title ?? null,
98
+ directory: meta.directory ?? null,
99
+ parentID: meta.parentID ?? null,
100
+ },
96
101
  }),
97
102
  }
98
103
  : {}),
@@ -137,6 +142,8 @@ const snap = (sessionID: string, over: Partial<SessionSnapshot> = {}): SessionSn
137
142
  session_created_at: null,
138
143
  session_title: null,
139
144
  session_directory: null,
145
+ parent_session_id: null,
146
+ root_session_id: null,
140
147
  last_activity_at: 1_000 + snapSeq,
141
148
  ...over,
142
149
  }
@@ -149,7 +156,9 @@ const SESSION_KEYS = [
149
156
  "last_activity_at",
150
157
  "message_count",
151
158
  "model",
159
+ "parent_session_id",
152
160
  "revision",
161
+ "root_session_id",
153
162
  "session_created_at",
154
163
  "session_directory",
155
164
  "session_id",
@@ -554,6 +563,181 @@ await runTest("session.get 不存在 → 三个元数据字段均为 null", asyn
554
563
  assertEqual(snapshot!.session_created_at, null, "created → null")
555
564
  })
556
565
 
566
+ /** 辅助:用给定 client 采集,返回指定会话快照的 root_session_id */
567
+ const collectAndRootOf = async (client: ReporterClient, trigger: string, want?: string): Promise<string | null> => {
568
+ const { core } = makeCore(client, { endpoint: "http://e" })
569
+ await core.collectSession(trigger)
570
+ const snap = core.queue.drainAll().find((s) => s.session_id === (want ?? trigger))
571
+ return snap ? snap.root_session_id : null
572
+ }
573
+
574
+ await runTest("session.get 返回 parentID → 快照/载荷带上 parent_session_id;顶层会话 root 等于自身", async () => {
575
+ // 顶层会话:无 parentID
576
+ const topClient = makeClient(
577
+ { ses_top: [assistantMessage()] },
578
+ {},
579
+ { parentID: null, title: "顶层任务", directory: "/opt/x" },
580
+ )
581
+ const { core } = makeCore(topClient, { endpoint: "http://e" })
582
+ await core.collectSession("ses_top")
583
+ const topSnap = core.queue.drainAll().find((s) => s.session_id === "ses_top")
584
+ assert(!!topSnap, "应产出快照")
585
+ assertEqual(topSnap!.parent_session_id, null, "顶层无父")
586
+ assertEqual(topSnap!.root_session_id, "ses_top", "自身即顶层 → root 等于 session_id")
587
+
588
+ // 带父会话的子会话 → 载荷验证
589
+ const childClient = makeClient({ ses_c: [assistantMessage()] }, { ses_p: ["ses_c"] }, { parentID: "ses_p" })
590
+ const { fetchFn, calls } = makeFetch("ok")
591
+ const q = createQueue(50)
592
+ const core2 = makeCore(childClient, { endpoint: "http://e", queue: q, fetchFn }).core
593
+ await core2.collectSession("ses_c")
594
+ const childSnap = q.drainAll().find((s) => s.session_id === "ses_c")
595
+ assert(!!childSnap, "子会话应产出快照")
596
+ assertEqual(childSnap!.parent_session_id, "ses_p", "快照 parent_session_id")
597
+ assertEqual(childSnap!.root_session_id, "ses_p", "root 解析为已验证的顶层")
598
+
599
+ q.push(childSnap!)
600
+ const core3 = makeCore(makeClient({}), { endpoint: "http://e", queue: q, fetchFn }).core
601
+ await core3.flushOnce()
602
+ const body = JSON.parse(calls[0]!.init.body ?? "{}")
603
+ assertEqual(body.sessions[0].parent_session_id, "ses_p", "载荷 parent_session_id")
604
+ assertEqual(body.sessions[0].root_session_id, "ses_p", "载荷 root_session_id")
605
+ })
606
+
607
+ await runTest("子会话触发采集时能上溯到根:root_session_id 等于顶层 id", async () => {
608
+ // 结构:ses_top(顶层) → ses_mid → ses_leaf
609
+ const client = makeClient(
610
+ { ses_top: [assistantMessage()], ses_mid: [assistantMessage()], ses_leaf: [assistantMessage()] },
611
+ { ses_top: ["ses_mid"], ses_mid: ["ses_leaf"] },
612
+ )
613
+ const getCalls: string[] = []
614
+ client.session.get = (async (args: { path: { id: string } }) => {
615
+ getCalls.push(args.path.id)
616
+ const parentID = args.path.id === "ses_top" ? null : args.path.id === "ses_mid" ? "ses_top" : "ses_mid"
617
+ return { data: { time: { created: 1 }, title: `t-${args.path.id}`, directory: "/d", parentID } }
618
+ }) as typeof client.session.get
619
+
620
+ const { core } = makeCore(client, { endpoint: "http://e" })
621
+ await core.collectSession("ses_leaf") // 由最深层子会话触发
622
+
623
+ const drained = core.queue.drainAll()
624
+ const leaf = drained.find((s) => s.session_id === "ses_leaf")
625
+ const mid = drained.find((s) => s.session_id === "ses_mid")
626
+ const top = drained.find((s) => s.session_id === "ses_top")
627
+ assert(!!leaf && !!mid && !!top, "从根开始应连带采集整棵树")
628
+ assertEqual(leaf!.root_session_id, "ses_top", "leaf root")
629
+ assertEqual(leaf!.parent_session_id, "ses_mid", "leaf parent")
630
+ assertEqual(mid!.root_session_id, "ses_top", "mid root")
631
+ assertEqual(top!.root_session_id, "ses_top", "top root 等于自身")
632
+ assertEqual(top!.parent_session_id, null, "top 无父")
633
+ assert(getCalls.includes("ses_top") && getCalls.includes("ses_mid") && getCalls.includes("ses_leaf"), "回溯与采集覆盖全链")
634
+ })
635
+
636
+ await runTest("同一子会话由不同入口触发,root_session_id 一致", async () => {
637
+ const parentOf = (id: string) => (id === "ses_top" ? null : "ses_top")
638
+ const buildClient = () => {
639
+ const client = makeClient(
640
+ { ses_top: [assistantMessage()], ses_child: [assistantMessage()] },
641
+ { ses_top: ["ses_child"] },
642
+ )
643
+ client.session.get = (async (args: { path: { id: string } }) => ({
644
+ data: { time: { created: 1 }, title: null, directory: null, parentID: parentOf(args.path.id) },
645
+ })) as typeof client.session.get
646
+ return client
647
+ }
648
+
649
+ // 入口一:子会话自身的空闲信号;入口二:顶层会话的空闲信号
650
+ const rootFromChild = await collectAndRootOf(buildClient(), "ses_child", "ses_child")
651
+ const rootFromTop = await collectAndRootOf(buildClient(), "ses_top", "ses_child")
652
+ assertEqual(rootFromChild, "ses_top", "子会话入口 → root=顶层")
653
+ assertEqual(rootFromTop, "ses_top", "顶层入口 → 子会话 root=顶层")
654
+ assertEqual(rootFromChild, rootFromTop, "两个入口解析出的 root 一致")
655
+ })
656
+
657
+ await runTest("祖先缺失(get 抛错)→ 以最近验证成功的会话为根,不崩溃不丢快照", async () => {
658
+ // 链:ses_child → ses_ghost(已删除,get 抛错)
659
+ const client = makeClient({ ses_child: [assistantMessage()] })
660
+ client.session.get = (async (args: { path: { id: string } }) => {
661
+ if (args.path.id === "ses_ghost") throw new Error("deleted session")
662
+ return {
663
+ data: {
664
+ time: { created: 1 },
665
+ title: null,
666
+ directory: null,
667
+ parentID: args.path.id === "ses_child" ? "ses_ghost" : null,
668
+ },
669
+ }
670
+ }) as typeof client.session.get
671
+
672
+ const { core } = makeCore(client, { endpoint: "http://e" })
673
+ await core.collectSession("ses_child")
674
+ const child = core.queue.drainAll().find((s) => s.session_id === "ses_child")
675
+ assert(!!child, "主流程不崩溃,快照仍产生")
676
+ assertEqual(child!.root_session_id, "ses_child", "以最近验证成功的会话为根")
677
+ assertEqual(child!.parent_session_id, "ses_ghost", "直接父保留(即使其已删除)")
678
+ })
679
+
680
+ await runTest("血缘完全不可解析(get 全抛错)→ 血缘为 null,退化为从传入 id 采集", async () => {
681
+ const client = makeClient({ ses_x: [assistantMessage()] }, {}, { throws: true })
682
+ const { core } = makeCore(client, { endpoint: "http://e" })
683
+ await core.collectSession("ses_x")
684
+ const snap = core.queue.drainAll().find((s) => s.session_id === "ses_x")
685
+ assert(!!snap, "不崩溃,快照仍产生")
686
+ assertEqual(snap!.root_session_id, null, "root 不可知 → null")
687
+ assertEqual(snap!.parent_session_id, null, "parent 不可知 → null")
688
+ })
689
+
690
+ await runTest("祖先已删除(get 返回 data 缺失)→ 回溯就地停止,root 为最近验证成功的会话", async () => {
691
+ // 链:ses_leaf → ses_mid(已删除,get 返回 data: undefined) → ses_root(实际不可达)
692
+ const client = makeClient({ ses_leaf: [assistantMessage()] })
693
+ client.session.get = (async (args: { path: { id: string } }) => {
694
+ if (args.path.id === "ses_mid") return { data: undefined }
695
+ if (args.path.id === "ses_leaf")
696
+ return { data: { time: { created: 1 }, title: null, directory: null, parentID: "ses_mid" } }
697
+ return { data: { time: { created: 1 }, title: null, directory: null, parentID: null } }
698
+ }) as typeof client.session.get
699
+
700
+ const { core } = makeCore(client, { endpoint: "http://e" })
701
+ await core.collectSession("ses_leaf")
702
+ const leaf = core.queue.drainAll().find((s) => s.session_id === "ses_leaf")
703
+ assert(!!leaf, "主流程不崩溃,快照仍产生")
704
+ assertEqual(leaf!.root_session_id, "ses_leaf", "root 应为最近真正取到元数据的 leaf")
705
+ assert(leaf!.root_session_id !== "ses_mid", "root 不得指向已删除的幽灵会话")
706
+ assertEqual(leaf!.parent_session_id, "ses_mid", "直接父保留(即使其已删除)")
707
+ })
708
+
709
+ await runTest("起始节点自身已删除(data 缺失)→ 血缘 null,退化为从传入 id 采集", async () => {
710
+ const client = makeClient({ ses_ghost: [assistantMessage()] })
711
+ client.session.get = (async () => ({ data: undefined })) as typeof client.session.get
712
+
713
+ const { core } = makeCore(client, { endpoint: "http://e" })
714
+ await core.collectSession("ses_ghost")
715
+ const snap = core.queue.drainAll().find((s) => s.session_id === "ses_ghost")
716
+ assert(!!snap, "不崩溃;messages 仍可用时快照照常产出")
717
+ assertEqual(snap!.root_session_id, null, "血缘不可知 → null")
718
+ assertEqual(snap!.parent_session_id, null, "parent 不可知 → null")
719
+ assertEqual(snap!.session_title, null, "title 不可知 → null")
720
+ assertEqual(snap!.session_directory, null, "directory 不可知 → null")
721
+ })
722
+
723
+ await runTest("存在但字段缺失(无 parentID/time)→ 视为存在的顶层,不误判为已删除", async () => {
724
+ const client = makeClient({ ses_min: [assistantMessage()] })
725
+ client.session.get = (async (args: { path: { id: string } }) => {
726
+ if (args.path.id === "ses_min") return { data: { id: "ses_min", title: "T", directory: "/d" } }
727
+ return { data: undefined }
728
+ }) as typeof client.session.get
729
+
730
+ const { core } = makeCore(client, { endpoint: "http://e" })
731
+ await core.collectSession("ses_min")
732
+ const snap = core.queue.drainAll().find((s) => s.session_id === "ses_min")
733
+ assert(!!snap, "应产出快照")
734
+ assertEqual(snap!.root_session_id, "ses_min", "存在的顶层 → root 等于自身")
735
+ assertEqual(snap!.parent_session_id, null, "无 parentID → null")
736
+ assertEqual(snap!.session_title, "T", "title 正常带上")
737
+ assertEqual(snap!.session_directory, "/d", "directory 正常带上")
738
+ assertEqual(snap!.session_created_at, null, "无 time → created 为 null")
739
+ })
740
+
557
741
  // ---------------------------------------------------------------------------
558
742
  // 汇总
559
743
  // ---------------------------------------------------------------------------
@@ -5,6 +5,9 @@
5
5
  * - 在会话空闲(bus 事件 `session.idle`,或 `session.status` 且 `status.type === "idle"`)时,
6
6
  * 读取该会话及其全部后代子会话的「已完成」助手消息 token 用量,按会话各自聚合为快照。
7
7
  * - 父会话快照绝不并入子会话用量;每个会话(含子代理会话)都是独立记录。
8
+ * - 血缘字段:parent_session_id = 直接父会话(session.get 的 parentID);root_session_id = 沿
9
+ * parentID 回溯到的顶层祖先(自身即顶层时等于 session_id)。祖先查不到(已删除/报错)时
10
+ * 就地停止,以最近一次验证成功的会话为根;完全不可解析时血缘为 null。
8
11
  * - token 口径原样分列上报(input / output / reasoning / cache.read / cache.write),
9
12
  * 不在客户端做任何相加或换算(`output` 不含 reasoning,`input` 不含 cache.read)。
10
13
  * - hook 只触发后台采集并入队,绝不等待网络;由后台 flush 统一 POST 到解析出的上报端点
@@ -43,7 +46,7 @@ import { dirname, join } from "node:path"
43
46
  // ---------------------------------------------------------------------------
44
47
 
45
48
  const SCHEMA_VERSION = 1
46
- const PLUGIN_VERSION = "0.1.3"
49
+ const PLUGIN_VERSION = "0.1.5"
47
50
  /** 队列容量:最多积压 500 条会话快照,溢出丢最旧 */
48
51
  const QUEUE_CAP = 500
49
52
  /** 单次上报请求超时(AbortSignal.timeout) */
@@ -57,6 +60,8 @@ const FLUSH_DEBOUNCE_MS = 250
57
60
  const DISPOSE_BUDGET_MS = 3_000
58
61
  /** 子会话递归深度上限(防御环/异常数据) */
59
62
  const MAX_SESSION_DEPTH = 32
63
+ /** 血缘回溯(向上找顶层祖先)深度上限(防御环/异常数据) */
64
+ const MAX_ANCESTOR_DEPTH = 20
60
65
 
61
66
  const INSTALL_ID_STATE_FILE = () => join(homedir(), ".local", "state", "opencode-session-reporter", "install_id")
62
67
 
@@ -113,6 +118,8 @@ export type SessionSnapshot = {
113
118
  session_created_at: number | null
114
119
  session_title: string | null
115
120
  session_directory: string | null
121
+ parent_session_id: string | null
122
+ root_session_id: string | null
116
123
  last_activity_at: number | null
117
124
  }
118
125
 
@@ -148,6 +155,14 @@ export type ReporterClient = {
148
155
  }
149
156
  }
150
157
 
158
+ /** 一次 session.get 能取到的会话元数据;null 表示本次调用失败 / 不可用(get 缺失、抛错、会话已删除) */
159
+ export type SessionMeta = {
160
+ created: number | null
161
+ title: string | null
162
+ directory: string | null
163
+ parentID: string | null
164
+ }
165
+
151
166
  /** 可注入的定时器(测试用手动调度器替换,避免真实 setTimeout) */
152
167
  export type ReporterScheduler = (fn: () => void, delayMs: number) => () => void
153
168
 
@@ -260,6 +275,8 @@ const aggregateSessionImpl = (sessionID: string, messages: readonly UsageMessage
260
275
  session_created_at: null, // 由采集层补充
261
276
  session_title: null, // 由采集层补充(session.get)
262
277
  session_directory: null, // 由采集层补充(session.get)
278
+ parent_session_id: null, // 由采集层补充(session.get 的 parentID)
279
+ root_session_id: null, // 由采集层补充(血缘回溯解析出的顶层祖先)
263
280
  last_activity_at: lastActivity,
264
281
  }
265
282
  }
@@ -517,52 +534,61 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
517
534
  return ids
518
535
  }
519
536
 
520
- const readSessionMeta = async (
521
- sessionID: string,
522
- ): Promise<{ created: number | null; title: string | null; directory: string | null }> => {
537
+ const fetchSessionMeta = async (sessionID: string): Promise<SessionMeta | null> => {
523
538
  try {
524
539
  const getSession = options.client.session.get
525
- if (typeof getSession !== "function") return { created: null, title: null, directory: null }
540
+ if (typeof getSession !== "function") return null
526
541
  const response = await getSession.call(options.client.session, { path: { id: sessionID }, query: query() })
527
542
  const data = (
528
543
  response as
529
- | { data?: { time?: { created?: unknown }; title?: unknown; directory?: unknown } }
544
+ | { data?: { time?: { created?: unknown }; title?: unknown; directory?: unknown; parentID?: unknown } }
530
545
  | undefined
531
546
  )?.data
532
- const created = data?.time?.created
533
- const title = data?.title
534
- const directory = data?.directory
547
+ // 「取不到会话」(data 缺失 / null / 非对象,典型为会话已删除)必须与
548
+ // 「取到了但某些字段缺失」区分开:前者返回 null,血缘回溯据此就地停止,
549
+ // 不得把该会话当作已验证的祖先(否则 root 会指向幽灵会话)。
550
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null
551
+ const created = data.time?.created
552
+ const parentID = data.parentID
535
553
  return {
536
554
  created: typeof created === "number" ? created : null,
537
- title: typeof title === "string" && title ? title : null,
538
- directory: typeof directory === "string" && directory ? directory : null,
555
+ title: typeof data.title === "string" && data.title ? data.title : null,
556
+ directory: typeof data.directory === "string" && data.directory ? data.directory : null,
557
+ parentID: typeof parentID === "string" && parentID ? parentID : null,
539
558
  }
540
559
  } catch {
541
- // session.get 抛错 / 形状异常:元数据缺省为 null,绝不影响采集主流程
542
- return { created: null, title: null, directory: null }
560
+ // session.get 抛错 / 形状异常:元数据不可用(null),绝不影响采集主流程
561
+ return null
543
562
  }
544
563
  }
545
564
 
546
- /** 递归聚合会话及其后代;每个会话独立成快照,父快照不含子用量 */
565
+ /**
566
+ * 递归聚合会话及其后代;每个会话独立成快照,父快照不含子用量。
567
+ * rootSessionID 为血缘回溯解析出的顶层祖先(不可解析时为 null);metaGet 为单次采集运行内的
568
+ * 元数据缓存读取器(血缘回溯与递归采集共用同一次 session.get 结果,不新增 API 请求)。
569
+ */
547
570
  const collectRecursive = async (
548
571
  sessionID: string,
549
572
  out: SessionSnapshot[],
550
573
  visited: Set<string>,
551
574
  depth: number,
575
+ rootSessionID: string | null,
576
+ metaGet: (id: string) => Promise<SessionMeta | null>,
552
577
  ): Promise<void> => {
553
578
  if (!sessionID || visited.has(sessionID) || depth > MAX_SESSION_DEPTH || visited.size >= QUEUE_CAP) return
554
579
  visited.add(sessionID)
555
580
  const snapshot = aggregateSessionImpl(sessionID, await readMessages(sessionID))
556
581
  if (snapshot) {
557
- // 复用同一次 session.get:created / title / directory 一并取得,不新增 API 请求
558
- const meta = await readSessionMeta(sessionID)
559
- snapshot.session_created_at = meta.created
560
- snapshot.session_title = meta.title
561
- snapshot.session_directory = meta.directory
582
+ const meta = await metaGet(sessionID)
583
+ snapshot.session_created_at = meta?.created ?? null
584
+ snapshot.session_title = meta?.title ?? null
585
+ snapshot.session_directory = meta?.directory ?? null
586
+ snapshot.parent_session_id = meta?.parentID ?? null
587
+ snapshot.root_session_id = rootSessionID
562
588
  out.push(snapshot)
563
589
  }
564
590
  for (const childID of await readChildIDs(sessionID)) {
565
- await collectRecursive(childID, out, visited, depth + 1)
591
+ await collectRecursive(childID, out, visited, depth + 1, rootSessionID, metaGet)
566
592
  }
567
593
  }
568
594
 
@@ -571,8 +597,37 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
571
597
  if (inFlight.has(sessionID)) return // 同一会话采集中:跳过,下次空闲信号再采
572
598
  inFlight.add(sessionID)
573
599
  try {
600
+ // 单次采集运行内的元数据缓存:血缘回溯与递归采集共用同一次 session.get 结果。
601
+ // 顶层会话的常见路径(get 一次、无 parent)不产生任何额外 API 请求。
602
+ const metaCache = new Map<string, SessionMeta | null>()
603
+ const metaGet = async (id: string): Promise<SessionMeta | null> => {
604
+ if (metaCache.has(id)) return metaCache.get(id) ?? null
605
+ const meta = await fetchSessionMeta(id)
606
+ metaCache.set(id, meta)
607
+ return meta
608
+ }
609
+
610
+ // 1) 血缘回溯:从触发会话沿 parentID 向上找顶层祖先。
611
+ // 边界:深度上限 MAX_ANCESTOR_DEPTH、visitedAncestors 防环(含自父);
612
+ // 祖先查不到(已删除 / get 抛错 / 不可用)就地停止,以「最近一次验证成功的会话」为根;
613
+ // 起始节点自身查不到 → 根不可知(null),退化为从传入 id 开始采集。
614
+ const visitedAncestors = new Set<string>([sessionID])
615
+ let current = sessionID
616
+ let verifiedRoot: string | null = null
617
+ for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth++) {
618
+ const meta = await metaGet(current)
619
+ if (!meta) break // 查不到:就地停止
620
+ verifiedRoot = current
621
+ const parentID = meta.parentID
622
+ if (!parentID || visitedAncestors.has(parentID)) break // 到顶 / 环 / 自父
623
+ visitedAncestors.add(parentID)
624
+ current = parentID
625
+ }
626
+
627
+ // 2) 从根开始递归采集(同一次空闲信号能连带刷新所有兄弟会话)
628
+ const startID = verifiedRoot ?? sessionID
574
629
  const out: SessionSnapshot[] = []
575
- await collectRecursive(sessionID, out, new Set(), 0)
630
+ await collectRecursive(startID, out, new Set(), 0, verifiedRoot, metaGet)
576
631
  for (const snapshot of out) queue.push(snapshot)
577
632
  if (out.length > 0) scheduleFlush()
578
633
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dijkspicy/opencode-stats-reporter",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "本地一体化 MaaS 用量看板:托管静态面板、反代用量接口、本地存储会话用量",
5
5
  "type": "module",
6
6
  "main": "./contrib/opencode-session-reporter/plugin-server.ts",
@@ -562,6 +562,34 @@ body {
562
562
  .sess-dir { max-width: 120px; }
563
563
  }
564
564
 
565
+ /* 任务分组:顶层会话行充当组头(徽标 + 组间留白),子会话缩进并以左侧竖线挂在任务下 */
566
+ #session-table tr.sess-task > td { padding-top: 14px; }
567
+ tr.sess-task .sess-title-line { display: flex; align-items: center; gap: 6px; }
568
+ .sess-task-badge {
569
+ flex: none;
570
+ padding: 0 7px;
571
+ border: 1px solid var(--border);
572
+ border-radius: 999px;
573
+ color: var(--text-secondary);
574
+ font-size: 10px;
575
+ font-weight: 600;
576
+ line-height: 16px;
577
+ }
578
+ #session-table tr.sess-child .sess-cell { position: relative; padding-left: 30px; }
579
+ #session-table tr.sess-child .sess-cell::before {
580
+ content: "";
581
+ position: absolute;
582
+ left: 14px;
583
+ top: 0;
584
+ bottom: 0;
585
+ width: 2px;
586
+ background: var(--border);
587
+ }
588
+
589
+ /* 根会话不在已加载结果中时的合成组头:只声明归属,不虚构任务名 */
590
+ tr.sess-orphan-head > td { font-size: 11px; color: var(--text-secondary); padding-top: 14px; }
591
+ #session-table tbody tr.sess-orphan-head:hover { background: transparent; }
592
+
565
593
  /* 会话 ID:默认弱化(有标题时),等宽头尾截断,完整值在 title,点击进详情 */
566
594
  .sess-id-btn {
567
595
  display: inline-block;
package/panel/index.html CHANGED
@@ -296,7 +296,7 @@
296
296
  <table id="session-table" class="session-table">
297
297
  <thead>
298
298
  <tr>
299
- <th title="优先显示会话标题,无标题时显示会话 ID;下方小字为会话所属项目目录,悬浮可看完整路径">会话</th>
299
+ <th title="同一顶层任务的会话归为一组:顶层会话标「任务」,子会话缩进挂在其下;组按最近活动排序,根会话不在当前结果时以灰字注明">会话</th>
300
300
  <th>模型</th>
301
301
  <th>代理</th>
302
302
  <th class="num" title="输入总量 = 输入 + 缓存读 + 缓存写">输入总量</th>
@@ -2,7 +2,7 @@
2
2
  * 会话视图:客户端上报的会话级 token 用量
3
3
  * 数据源:/v1/sessions*(客户端上报,可能延迟/遗漏,非网关权威数据)
4
4
  * token 口径(已实测验证):输入总量 = 输入 + 缓存读 + 缓存写;输出总量 = 输出 + 推理(推理单列,不并入输出)
5
- * 结构:来源声明 → 用量对账(网关权威总量 − 会话上报合计 = 未归属用量)→ 会话列表(窗口/模型/代理筛选 + 游标分页)→ 会话详情
5
+ * 结构:来源声明 → 用量对账(网关权威总量 − 会话上报合计 = 未归属用量)→ 会话列表(按顶层任务分组 + 窗口/模型/代理筛选 + 游标分页)→ 会话详情
6
6
  * 由 app.js 初始化:BRU.sessions.init({ getCfg, getPeriod, getModelCatalog, onUnauthorized }),并经 BRU.sessions.load() 触发拉取
7
7
  */
8
8
  (function () {
@@ -190,10 +190,57 @@
190
190
  }
191
191
 
192
192
  // ---------- 会话列表 ----------
193
+ /**
194
+ * 按顶层任务组织页内呈现(不改变拉取与游标逻辑,服务端排序保持 last_activity_at 倒序):
195
+ * - root_session_id 为空或等于自身 → 独立任务行(契约:顶层会话 root 等于自身;旧数据为 null 时同样按顶层处理)
196
+ * - 子会话按 root_session_id 归组;根已加载则根行充当组头,根不在已加载行中则落合成组头(orphanHeadRow)
197
+ * - 组间按组内最近活动倒序;组内根在前,子会话保持服务端返回顺序(即最近活动倒序)
198
+ */
193
199
  function renderRows() {
194
200
  var tbody = el.tbody;
195
201
  tbody.innerHTML = '';
196
- st.rows.forEach(function (r) { tbody.appendChild(sessionRow(r)); });
202
+ buildGroups(st.rows).forEach(function (g) {
203
+ if (g.rootRow) tbody.appendChild(sessionRow(g.rootRow, 'task'));
204
+ else tbody.appendChild(orphanHeadRow(g.key));
205
+ g.children.forEach(function (r) { tbody.appendChild(sessionRow(r, 'child')); });
206
+ });
207
+ }
208
+
209
+ function buildGroups(rows) {
210
+ var map = {};
211
+ var list = [];
212
+ function group(key, idx) {
213
+ if (!map[key]) { map[key] = { key: key, rootRow: null, children: [], newest: -Infinity, idx: idx }; list.push(map[key]); }
214
+ return map[key];
215
+ }
216
+ rows.forEach(function (r, i) {
217
+ var sid = nonEmpty(r.session_id);
218
+ var rootId = nonEmpty(r.root_session_id);
219
+ var isTop = !sid || !rootId || rootId === sid;
220
+ var g = group(isTop ? (sid || '\u0000' + i) : rootId, i);
221
+ if (isTop) g.rootRow = r;
222
+ else g.children.push(r);
223
+ var act = Number(r.last_activity_at);
224
+ if (isFinite(act) && act > g.newest) g.newest = act;
225
+ });
226
+ list.sort(function (a, b) { return b.newest - a.newest || a.idx - b.idx; });
227
+ return list;
228
+ }
229
+
230
+ /** 根会话不在已加载行中(分页未到 / 窗口或筛选未含根 / 旧数据)时的合成组头:只声明归属,不虚构任务名 */
231
+ function orphanHeadRow(rootId) {
232
+ var tr = document.createElement('tr');
233
+ tr.className = 'sess-orphan-head';
234
+ var c = document.createElement('td');
235
+ c.colSpan = 10;
236
+ c.textContent = '所属顶层会话不在当前列表 · ';
237
+ var id = document.createElement('span');
238
+ id.className = 'mono';
239
+ id.textContent = shortId(rootId);
240
+ id.title = rootId + '\n根会话 ID(未在当前结果中)';
241
+ c.appendChild(id);
242
+ tr.appendChild(c);
243
+ return tr;
197
244
  }
198
245
 
199
246
  /** 空值守卫:非字符串或空白串返回 null,避免把 null/undefined/空串渲染到页面 */
@@ -214,6 +261,16 @@
214
261
  return '…/' + parts.slice(-2).join('/');
215
262
  }
216
263
 
264
+ /**
265
+ * 子代理会话标题尾部「 (@xxx subagent)」与「代理」列信息重复,展示时去掉;完整原标题始终保留在悬浮。
266
+ * 仅在后缀中的代理名与该行 agent 字段一致时才去(不一致说明后缀另有含义,原样保留)。
267
+ */
268
+ function displayTitle(title, agent) {
269
+ var m = title.match(/\s*\(@([^\s()]+) subagent\)$/);
270
+ if (m && agent && m[1] === agent) return title.slice(0, m.index).replace(/\s+$/, '');
271
+ return title;
272
+ }
273
+
217
274
  /** 会话 ID 按钮:等宽展示(头尾截断),完整值放 title,点击进详情;primary = 无标题时作为降级主标识 */
218
275
  function idButton(sid, primary) {
219
276
  var b = document.createElement('button');
@@ -229,9 +286,12 @@
229
286
  * 首列「会话」:两行结构
230
287
  * 主行:会话标题(加粗);无标题时降级为会话 ID(等宽、高亮、可点)
231
288
  * 次行:项目目录(末两级压缩,悬浮显全路径)· 会话 ID(弱化;ID 已作主标识时不重复)
289
+ * role:task = 顶层任务行(带「任务」徽标,作为组头);child = 子会话(CSS 缩进 + 挂线);其余 = 独立行
232
290
  */
233
- function sessionRow(r) {
291
+ function sessionRow(r, role) {
234
292
  var tr = document.createElement('tr');
293
+ if (role === 'task') tr.className = 'sess-task';
294
+ else if (role === 'child') tr.className = 'sess-child';
235
295
  var title = nonEmpty(r.session_title);
236
296
  var dir = nonEmpty(r.session_directory);
237
297
  var sid = nonEmpty(r.session_id) || '--';
@@ -241,10 +301,16 @@
241
301
 
242
302
  var line1 = document.createElement('div');
243
303
  line1.className = 'sess-title-line';
304
+ if (role === 'task') {
305
+ var badge = document.createElement('span');
306
+ badge.className = 'sess-task-badge';
307
+ badge.textContent = '任务';
308
+ line1.appendChild(badge);
309
+ }
244
310
  if (title) {
245
311
  var t = document.createElement('span');
246
312
  t.className = 'sess-title';
247
- t.textContent = title;
313
+ t.textContent = displayTitle(title, nonEmpty(r.agent));
248
314
  t.title = title + '\n点击查看详情';
249
315
  t.addEventListener('click', function () { openDetail(sid); });
250
316
  line1.appendChild(t);
@@ -381,6 +447,8 @@
381
447
  body.appendChild(group('会话信息', [
382
448
  item('会话标题', nonEmpty(d.session_title) || '--'),
383
449
  item('项目目录', nonEmpty(d.session_directory) || '--', true),
450
+ item('根会话', nonEmpty(d.root_session_id) || '--', true),
451
+ item('父会话', nonEmpty(d.parent_session_id) || '--', true),
384
452
  item('模型', d.model || '--'),
385
453
  item('代理', d.agent || '--'),
386
454
  item('安装标识', d.install_id || '--', true),
@@ -73,6 +73,7 @@ export function listSessions(store, consumer, params) {
73
73
  model: params.get("model") || undefined,
74
74
  agent: params.get("agent") || undefined,
75
75
  installId: params.get("install_id") || undefined,
76
+ rootSessionId: params.get("root_session_id") || undefined,
76
77
  limit,
77
78
  cursor: cursorRaw ? decodeCursor(cursorRaw) : null,
78
79
  })
package/src/store.js CHANGED
@@ -31,6 +31,8 @@ CREATE TABLE IF NOT EXISTS session_usage (
31
31
  last_reported_at INTEGER NOT NULL,
32
32
  session_title TEXT,
33
33
  session_directory TEXT,
34
+ parent_session_id TEXT,
35
+ root_session_id TEXT,
34
36
  PRIMARY KEY (consumer_id, install_id, session_id)
35
37
  );
36
38
  CREATE INDEX IF NOT EXISTS idx_su_consumer_activity ON session_usage(consumer_id, last_activity_at DESC);
@@ -93,6 +95,8 @@ export function sanitizeRecord(raw) {
93
95
  client_session_header: str(raw.client_session_header),
94
96
  session_title: str(raw.session_title, 300),
95
97
  session_directory: str(raw.session_directory, 512),
98
+ parent_session_id: str(raw.parent_session_id, 200),
99
+ root_session_id: str(raw.root_session_id, 200),
96
100
  }
97
101
  }
98
102
 
@@ -125,7 +129,12 @@ export class Store {
125
129
  const existing = new Set(
126
130
  this.db.prepare("PRAGMA table_info(session_usage)").all().map((r) => r.name),
127
131
  )
128
- for (const [name, type] of [["session_title", "TEXT"], ["session_directory", "TEXT"]]) {
132
+ for (const [name, type] of [
133
+ ["session_title", "TEXT"],
134
+ ["session_directory", "TEXT"],
135
+ ["parent_session_id", "TEXT"],
136
+ ["root_session_id", "TEXT"],
137
+ ]) {
129
138
  if (!existing.has(name)) {
130
139
  this.db.exec(`ALTER TABLE session_usage ADD COLUMN ${name} ${type}`)
131
140
  }
@@ -149,19 +158,22 @@ export class Store {
149
158
  let accepted = 0
150
159
  let skipped = 0
151
160
  const find = this.db.prepare(
152
- "SELECT revision, last_activity_at FROM session_usage WHERE consumer_id=? AND install_id=? AND session_id=?",
161
+ "SELECT revision, last_activity_at, session_title, session_directory, parent_session_id, root_session_id FROM session_usage WHERE consumer_id=? AND install_id=? AND session_id=?",
162
+ )
163
+ const backfill = this.db.prepare(
164
+ "UPDATE session_usage SET session_title=?, session_directory=?, parent_session_id=?, root_session_id=?, last_reported_at=? WHERE consumer_id=? AND install_id=? AND session_id=?",
153
165
  )
154
166
  const insert = this.db.prepare(`INSERT INTO session_usage
155
167
  (consumer_id, install_id, session_id, source, schema_version, reporter_version, client_session_header,
156
168
  model, agent, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
157
169
  cost, message_count, revision, session_created_at, last_activity_at, client_reported_at,
158
- first_seen_at, last_reported_at, session_title, session_directory)
159
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
170
+ first_seen_at, last_reported_at, session_title, session_directory, parent_session_id, root_session_id)
171
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
160
172
  const update = this.db.prepare(`UPDATE session_usage SET
161
173
  source=?, schema_version=?, reporter_version=?, client_session_header=?, model=?, agent=?,
162
174
  tokens_input=?, tokens_output=?, tokens_reasoning=?, tokens_cache_read=?, tokens_cache_write=?,
163
175
  cost=?, message_count=?, revision=?, session_created_at=?, last_activity_at=?, client_reported_at=?,
164
- last_reported_at=?, session_title=?, session_directory=?
176
+ last_reported_at=?, session_title=?, session_directory=?, parent_session_id=?, root_session_id=?
165
177
  WHERE consumer_id=? AND install_id=? AND session_id=?`)
166
178
 
167
179
  for (const r of records) {
@@ -173,7 +185,7 @@ export class Store {
173
185
  consumerId, r.install_id, r.session_id, source, schemaVersion, reporterVersion, r.client_session_header,
174
186
  r.model, r.agent, r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
175
187
  r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts, ts,
176
- r.session_title, r.session_directory,
188
+ r.session_title, r.session_directory, r.parent_session_id, r.root_session_id,
177
189
  )
178
190
  } else if (r.revision > prev.revision || (r.revision === prev.revision && r.last_activity_at > prev.last_activity_at)) {
179
191
  status = "upserted"
@@ -181,22 +193,39 @@ export class Store {
181
193
  source, schemaVersion, reporterVersion, r.client_session_header, r.model, r.agent,
182
194
  r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
183
195
  r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts,
184
- r.session_title, r.session_directory,
196
+ r.session_title, r.session_directory, r.parent_session_id, r.root_session_id,
185
197
  consumerId, r.install_id, r.session_id,
186
198
  )
187
199
  } else if (r.revision === prev.revision && r.last_activity_at === prev.last_activity_at) {
188
- status = "unchanged"
200
+ // 单调守卫内(revision 与活动时间均未推进):仅当元数据(含血缘)变化时做「窄更新」回填,
201
+ // 只写 session_title / session_directory / parent_session_id / root_session_id / last_reported_at,
202
+ // 不触碰 token 类字段、revision、last_activity_at、source、reporter_version 等计量与守卫字段。
203
+ const metaChanged =
204
+ (r.session_title ?? null) !== (prev.session_title ?? null) ||
205
+ (r.session_directory ?? null) !== (prev.session_directory ?? null) ||
206
+ (r.parent_session_id ?? null) !== (prev.parent_session_id ?? null) ||
207
+ (r.root_session_id ?? null) !== (prev.root_session_id ?? null)
208
+ if (metaChanged) {
209
+ status = "backfilled"
210
+ backfill.run(
211
+ r.session_title ?? null, r.session_directory ?? null,
212
+ r.parent_session_id ?? null, r.root_session_id ?? null,
213
+ ts, consumerId, r.install_id, r.session_id,
214
+ )
215
+ } else {
216
+ status = "unchanged"
217
+ }
189
218
  } else {
190
219
  status = "stale"
191
220
  }
192
- if (status === "upserted" || status === "unchanged") accepted += 1
221
+ if (status === "upserted" || status === "unchanged" || status === "backfilled") accepted += 1
193
222
  else skipped += 1
194
223
  results.push({ session_id: r.session_id, install_id: r.install_id, status })
195
224
  }
196
225
  return { accepted, skipped, results }
197
226
  }
198
227
 
199
- listSessions(consumerId, { window, model, agent, installId, limit = 50, cursor = null } = {}) {
228
+ listSessions(consumerId, { window, model, agent, installId, rootSessionId, limit = 50, cursor = null } = {}) {
200
229
  const where = ["consumer_id=?"]
201
230
  const args = [consumerId]
202
231
  const start = windowStartMs(window)
@@ -216,6 +245,11 @@ export class Store {
216
245
  where.push("install_id=?")
217
246
  args.push(installId)
218
247
  }
248
+ if (rootSessionId) {
249
+ // 按顶层任务筛选:同一 root 下的全部会话(含根自身)
250
+ where.push("root_session_id=?")
251
+ args.push(rootSessionId)
252
+ }
219
253
  if (cursor) {
220
254
  where.push("(last_activity_at < ? OR (last_activity_at = ? AND session_id < ?))")
221
255
  args.push(cursor[0], cursor[0], cursor[1])