opencode-metrics-plugin 0.4.0 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -1,9 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3(2026-09-16)
4
+
5
+ - **修复 AGC 上传 400**:Node 原生 http.request 默认不发送 `User-Agent`,AGC 云存储 2026-09-16 起强制校验该头(此前 0.4.2 及更早版本同缺陷,服务端新策略触发)——上传 PUT 显式携带 `User-Agent: opencode-metrics-plugin/<version>`。
6
+
7
+ ## 0.4.2(2026-09-16)
8
+
9
+ - **代码行多维统计**(工程全量扫描口径,替代 write 工具累计):`codeStats` 新增 `cLines` / `cppLines` / `testLines` / `businessCodeLines`,`etsLines` 改为鸿蒙工程全量扫描(排除测试)。根发现 = 会话工作目录 + bash workdir + write/edit 文件目录(向上提升到鸿蒙工程根 + 安卓工程的兄弟鸿蒙工程探测,mtime 时间窗防邻居混入);`.h` 计入 C、`.hpp` 等计入 C++(头文件统一归入语言维度);测试目录(ohosTest/test/mock 等)子树与 `.test.`/`.spec.` 命名文件计为测试维度(任意语言,不与语言维度重复计数);构建/配置文件(hvigorfile.ts、CMakeLists.txt、.json5)不计入。flush 时 30s 节流扫描,候选根集合变化立即重扫。配套 session-viewer Code Stats 卡片展示五维。
10
+ - **修复 droid2hmos 包装器构建漏记**:`droid2hmos build/test harmony` 内部执行 hvigorw 但命令行不含 hvigorw、输出仅状态摘要(hvigor 原始输出按迁移工作台规则重定向日志文件)——按命令形态识别为构建调用,摘要含 `failed` 标记判失败(曾致整个迁移会话 codeStats 构建统计为空)。
11
+ - **修复 git commit 场景漏触发**:内置 git-commit 正则支持 `git` 与 `commit` 之间出现选项(`-c user.name=x`、`-C <path>`、`--no-verify` 等,≤4 个),`git log` 类命令不误报(曾致 `git -c … commit` 形式的提交全部漏上传)。
12
+ - **修复启动清扫误删完整上传包**:清扫改两遍扫描——第一遍只处理 `.tar.gz`(三件套校验→移入 failed/ 待重传,并标记保护),第二遍清理孤儿 `.json`/组装残留。字母序下单遍边迭代边删 `.json` 会必然破坏三件套校验,崩溃遗留的完整包被当垃圾销毁(曾连续销毁终态包致云端缺 43 分钟数据)。
13
+ - **孤儿补传 .sent 时效判断**:已传标记只证明"标记时刻之前的快照已上云",快照 mtime 晚于标记时间(>60s 缓冲)仍纳入孤儿补传——中途终态触发(task 完成/session.deleted)上传后继续运行的会话,最终态不再永久漏传。
14
+
15
+ ## 0.4.1(2026-09-16)
16
+
17
+ - 新增 **AI 活跃时长**指标(快照顶层 `aiActiveDuration` / `aiActiveRatio` / `waitBreakdown`,轮级 `activeDuration` / `questionMs` / `permissionMs` / `retryMs` / `retryCount`):从轮墙钟中扣减已知等待——question 工具等待用户回答、权限审批(`permission.asked`/`replied` 按 ID 配对,两事件键名不对称:`id` vs `requestID`)、apikey 限流挂起(`session.status` retry→busy 状态机);step 间死时间计入活跃。`waitBreakdown.otherIdleMs` 为轮间空闲,满足恒等式 `duration = aiActiveDuration + questionMs + permissionMs + retryMs + otherIdleMs`。
18
+ - 轮闭合时未决等待截断入轮(孤儿权限询问、挂起重试、未答 question),计时重锚防跨轮双计;子代理会话的权限/限流/question 等待同样扣减父轮。
19
+ - 合并兼容:旧版(≤0.4.0)快照无新字段,合并时旧轮回退按 `duration` 计(视为全活跃);会话级汇总从合并后轮数组重算,重启不丢不双计。配套 session-viewer 已做前向兼容(快照有值优先,旧快照回退 viewer 侧推导)。
20
+
3
21
  ## 0.4.0(2026-09-15)
4
22
 
5
23
  - 新增会话 agent 白名单 `agents`(支持 `*` 通配,如 `["android-to-hmos-*", "build"]`):配置后只有根会话 agent 命中任一模式才记录——快照、事件日志、system prompt、上传全不产生;子会话(task/explore 等)随父会话判定正常并入;agent 未知的会话默认记录(fail-open)。缺省/空数组 = 不过滤全记(向后兼容)。
6
- - 典型用法:全局 `~/.config/opencode/opencode.json` 注册 `["opencode-metrics-plugin", { "agents": ["android-to-hmos-*"] }]`,实现「插件处处加载、只统计迁移 agent 的会话」,不受 opencode 项目级配置发现的 git 边界限制。注意项目 `.opencode/opencode-metrics.json(c)` 存在时仍会完全接管(含 `agents`),接管文件需同步配置。
24
+ - 典型用法:全局 `~/.config/opencode/opencode.json` 注册 `["opencode-metrics-plugin", { "agents": ["android-to-hmos-00-orchestrator"] }]`,实现「插件处处加载、只统计迁移 agent 的会话」,不受 opencode 项目级配置发现的 git 边界限制。注意项目 `.opencode/opencode-metrics.json(c)` 存在时仍会完全接管(含 `agents`),接管文件需同步配置。
7
25
 
8
26
  ## 0.3.5(2026-09-15)
9
27
 
@@ -0,0 +1,39 @@
1
+ /**
2
+ * codelines.ts — 会话关联鸿蒙工程的代码行多维统计。
3
+ *
4
+ * 口径(0.4.2 起,替代旧的 write 工具累计口径):
5
+ * - etsLines / cLines / cppLines:排除测试代码的各语言非空行数(.h 计入 C、
6
+ * .hpp 等计入 C++,头文件统一归入语言维度);
7
+ * - testLines:测试目录(ohosTest/test/tests/mock/mocks/__tests__)子树及
8
+ * *.test.* / *.spec.* 文件的代码行(任意语言,不与语言维度重复计数);
9
+ * - businessCodeLines:实际业务代码总量 = ets + C + C++ + 测试(构建/配置
10
+ * 文件 hvigorfile.ts、CMakeLists.txt、.json5 等不计入,故等于全量)。
11
+ *
12
+ * 根发现:候选路径(会话工作目录 + bash workdir + write/edit 文件目录)向上
13
+ * 提升到鸿蒙工程根(标记文件);非鸿蒙目录(如安卓源工程)在父目录一级探测
14
+ * 兄弟鸿蒙工程(mtime ≥ 会话起点,防邻居旧工程混入)。
15
+ */
16
+ export interface CodeLineStats {
17
+ etsLines: number;
18
+ cLines: number;
19
+ cppLines: number;
20
+ testLines: number;
21
+ /** 实际业务代码总量 = ets + C + C++ + 测试(= 全量) */
22
+ businessCodeLines: number;
23
+ }
24
+ /**
25
+ * 从候选路径集合发现鸿蒙工程根:向上提升(≤8 层)+ 安卓工程的兄弟鸿蒙工程
26
+ * 探测(父目录一级,mtime 时间窗 [sessionStartedAt-60s, ∞))。
27
+ *
28
+ * @param candidates 候选路径(工作目录 / bash workdir / write|edit 文件目录)
29
+ * @param sessionStartedAt 会话起点(毫秒),兄弟工程 mtime 时间窗下界
30
+ * @returns 去重后的鸿蒙工程根绝对路径数组
31
+ */
32
+ export declare function discoverHarmonyRoots(candidates: readonly string[], sessionStartedAt: number): string[];
33
+ /**
34
+ * 扫描鸿蒙工程根集合,输出多维代码行统计。语言判定基于扩展名;测试目录
35
+ * 子树与测试命名文件全部计入 testLines,不与语言维度重复计数。
36
+ *
37
+ * @param roots 鸿蒙工程根绝对路径数组(重叠根自动按文件去重)
38
+ */
39
+ export declare function scanCodeLines(roots: readonly string[]): CodeLineStats;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * codelines.ts — 会话关联鸿蒙工程的代码行多维统计。
3
+ *
4
+ * 口径(0.4.2 起,替代旧的 write 工具累计口径):
5
+ * - etsLines / cLines / cppLines:排除测试代码的各语言非空行数(.h 计入 C、
6
+ * .hpp 等计入 C++,头文件统一归入语言维度);
7
+ * - testLines:测试目录(ohosTest/test/tests/mock/mocks/__tests__)子树及
8
+ * *.test.* / *.spec.* 文件的代码行(任意语言,不与语言维度重复计数);
9
+ * - businessCodeLines:实际业务代码总量 = ets + C + C++ + 测试(构建/配置
10
+ * 文件 hvigorfile.ts、CMakeLists.txt、.json5 等不计入,故等于全量)。
11
+ *
12
+ * 根发现:候选路径(会话工作目录 + bash workdir + write/edit 文件目录)向上
13
+ * 提升到鸿蒙工程根(标记文件);非鸿蒙目录(如安卓源工程)在父目录一级探测
14
+ * 兄弟鸿蒙工程(mtime ≥ 会话起点,防邻居旧工程混入)。
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ /** 鸿蒙工程标记:命中其一即视为工程根 */
19
+ const HARMONY_MARKERS = ["oh-package.json5", "build-profile.json5", "hvigorfile.ts", "AppScope"];
20
+ /** 扫描时跳过的目录名(小写);ohosTest/test 等测试目录不跳过、整树计为测试 */
21
+ const EXCLUDED_DIRS = new Set([
22
+ "oh_modules", "node_modules", "build", ".hvigor", ".cxx", ".preview",
23
+ ".git", ".clangd", ".idea", ".vscode", ".cache", "cxx-deps",
24
+ ]);
25
+ /** 目录名命中(小写)即整棵子树视为测试代码 */
26
+ const TEST_DIRS = new Set(["ohostest", "test", "tests", "mock", "mocks", "__tests__"]);
27
+ /** 文件名命中即视为测试代码(任意目录下) */
28
+ const TEST_FILE_RE = /\.(test|spec)\.[^.]+$/;
29
+ const CPP_EXTS = new Set([".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"]);
30
+ const C_EXTS = new Set([".c", ".h"]);
31
+ /** Windows 大小写不敏感的路径归一化键 */
32
+ function normKey(p) {
33
+ const resolved = path.resolve(p);
34
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
35
+ }
36
+ function hasHarmonyMarkers(dir) {
37
+ try {
38
+ return HARMONY_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)));
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * 从候选路径集合发现鸿蒙工程根:向上提升(≤8 层)+ 安卓工程的兄弟鸿蒙工程
46
+ * 探测(父目录一级,mtime 时间窗 [sessionStartedAt-60s, ∞))。
47
+ *
48
+ * @param candidates 候选路径(工作目录 / bash workdir / write|edit 文件目录)
49
+ * @param sessionStartedAt 会话起点(毫秒),兄弟工程 mtime 时间窗下界
50
+ * @returns 去重后的鸿蒙工程根绝对路径数组
51
+ */
52
+ export function discoverHarmonyRoots(candidates, sessionStartedAt) {
53
+ const roots = [];
54
+ const seen = new Set();
55
+ const pushRoot = (dir) => {
56
+ const key = normKey(dir);
57
+ if (!seen.has(key)) {
58
+ seen.add(key);
59
+ roots.push(dir);
60
+ }
61
+ };
62
+ for (const candidate of candidates) {
63
+ if (!candidate || typeof candidate !== "string")
64
+ continue;
65
+ let dir;
66
+ try {
67
+ dir = path.resolve(candidate);
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ if (seen.has(normKey(dir)))
73
+ continue;
74
+ // ① 向上提升到鸿蒙工程根
75
+ let found = null;
76
+ let cursor = dir;
77
+ for (let i = 0; i < 8; i++) {
78
+ if (hasHarmonyMarkers(cursor)) {
79
+ found = cursor;
80
+ break;
81
+ }
82
+ const parent = path.dirname(cursor);
83
+ if (parent === cursor)
84
+ break;
85
+ cursor = parent;
86
+ }
87
+ if (found) {
88
+ pushRoot(found);
89
+ continue;
90
+ }
91
+ // ② 非鸿蒙目录(如安卓源工程根/子目录):父目录一级探测兄弟鸿蒙工程
92
+ try {
93
+ const parent = path.dirname(dir);
94
+ for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
95
+ if (!entry.isDirectory())
96
+ continue;
97
+ const sibling = path.join(parent, entry.name);
98
+ if (normKey(sibling) === normKey(dir))
99
+ continue;
100
+ if (seen.has(normKey(sibling)))
101
+ continue;
102
+ if (!hasHarmonyMarkers(sibling))
103
+ continue;
104
+ // mtime 时间窗:兄弟工程须在会话起点后仍被写过(留 60s 时钟余量)
105
+ try {
106
+ if (fs.statSync(sibling).mtimeMs < sessionStartedAt - 60_000)
107
+ continue;
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ pushRoot(sibling);
113
+ }
114
+ }
115
+ catch {
116
+ // 父目录不可读:跳过兄弟探测
117
+ }
118
+ }
119
+ return roots;
120
+ }
121
+ /** 统计单个文件的非空行数(读取失败返回 0) */
122
+ function countNonEmptyLines(filePath) {
123
+ try {
124
+ const content = fs.readFileSync(filePath, "utf-8");
125
+ let count = 0;
126
+ for (const line of content.split(/\r?\n/)) {
127
+ if (line.length > 0)
128
+ count++;
129
+ }
130
+ return count;
131
+ }
132
+ catch {
133
+ return 0;
134
+ }
135
+ }
136
+ /**
137
+ * 扫描鸿蒙工程根集合,输出多维代码行统计。语言判定基于扩展名;测试目录
138
+ * 子树与测试命名文件全部计入 testLines,不与语言维度重复计数。
139
+ *
140
+ * @param roots 鸿蒙工程根绝对路径数组(重叠根自动按文件去重)
141
+ */
142
+ export function scanCodeLines(roots) {
143
+ const stats = {
144
+ etsLines: 0, cLines: 0, cppLines: 0, testLines: 0, businessCodeLines: 0,
145
+ };
146
+ const seenFiles = new Set();
147
+ const classifyFile = (filePath, inTestDir) => {
148
+ const ext = path.extname(filePath).toLowerCase();
149
+ let lang = null;
150
+ if (ext === ".ets")
151
+ lang = "ets";
152
+ else if (C_EXTS.has(ext))
153
+ lang = "c";
154
+ else if (CPP_EXTS.has(ext))
155
+ lang = "cpp";
156
+ if (!lang)
157
+ return;
158
+ const key = normKey(filePath);
159
+ if (seenFiles.has(key))
160
+ return;
161
+ seenFiles.add(key);
162
+ const lines = countNonEmptyLines(filePath);
163
+ if (inTestDir || TEST_FILE_RE.test(path.basename(filePath))) {
164
+ stats.testLines += lines;
165
+ }
166
+ else if (lang === "ets") {
167
+ stats.etsLines += lines;
168
+ }
169
+ else if (lang === "c") {
170
+ stats.cLines += lines;
171
+ }
172
+ else {
173
+ stats.cppLines += lines;
174
+ }
175
+ };
176
+ const countTestTree = (dir) => {
177
+ let entries;
178
+ try {
179
+ entries = fs.readdirSync(dir, { withFileTypes: true });
180
+ }
181
+ catch {
182
+ return;
183
+ }
184
+ for (const entry of entries) {
185
+ const full = path.join(dir, entry.name);
186
+ if (entry.isDirectory()) {
187
+ if (EXCLUDED_DIRS.has(entry.name.toLowerCase()))
188
+ continue;
189
+ countTestTree(full);
190
+ }
191
+ else if (entry.isFile()) {
192
+ classifyFile(full, true);
193
+ }
194
+ }
195
+ };
196
+ const walk = (dir) => {
197
+ let entries;
198
+ try {
199
+ entries = fs.readdirSync(dir, { withFileTypes: true });
200
+ }
201
+ catch {
202
+ return;
203
+ }
204
+ for (const entry of entries) {
205
+ const full = path.join(dir, entry.name);
206
+ if (entry.isDirectory()) {
207
+ const nameLower = entry.name.toLowerCase();
208
+ if (EXCLUDED_DIRS.has(nameLower))
209
+ continue;
210
+ if (TEST_DIRS.has(nameLower)) {
211
+ countTestTree(full);
212
+ continue;
213
+ }
214
+ walk(full);
215
+ }
216
+ else if (entry.isFile()) {
217
+ classifyFile(full, false);
218
+ }
219
+ }
220
+ };
221
+ for (const root of roots)
222
+ walk(root);
223
+ stats.businessCodeLines = stats.etsLines + stats.cLines + stats.cppLines + stats.testLines;
224
+ return stats;
225
+ }
@@ -37,4 +37,11 @@ export declare function stripAnsi(raw: string): string;
37
37
  export declare function extractErrorCodes(stripped: string): ErrorCodeEntry[];
38
38
  export declare function extractWarnings(stripped: string): WarningEntry[];
39
39
  export declare function extractModuleTimings(stripped: string): Map<string, ModuleTiming>;
40
+ /**
41
+ * 解析 droid2hmos 包装器(build/test harmony)的摘要输出:包装器内部执行
42
+ * hvigorw,但按迁移工作台规则 hvigor 原始输出重定向到日志文件,工具输出只有
43
+ * 状态摘要(status 表:passed/failed)。无 failed 标记视为成功(命令已完成);
44
+ * 错误码/告警/模块计时在摘要中不存在,返回空集合。
45
+ */
46
+ export declare function parseD2hWrapperOutput(raw: string): CompileResult;
40
47
  export declare function parseHvigorwOutput(raw: string): CompileResult;
@@ -130,6 +130,26 @@ function addTiming(timings, moduleName, taskName, duration) {
130
130
  });
131
131
  }
132
132
  }
133
+ // ─── parseD2hWrapperOutput ──────────────────────────────────────────────────
134
+ /**
135
+ * 解析 droid2hmos 包装器(build/test harmony)的摘要输出:包装器内部执行
136
+ * hvigorw,但按迁移工作台规则 hvigor 原始输出重定向到日志文件,工具输出只有
137
+ * 状态摘要(status 表:passed/failed)。无 failed 标记视为成功(命令已完成);
138
+ * 错误码/告警/模块计时在摘要中不存在,返回空集合。
139
+ */
140
+ export function parseD2hWrapperOutput(raw) {
141
+ const stripped = stripAnsi(raw).toLowerCase();
142
+ const failed = /(^|[^a-z])failed([^a-z]|$)/m.test(stripped);
143
+ return {
144
+ success: !failed,
145
+ duration: 0,
146
+ errorCount: 0,
147
+ warnCount: 0,
148
+ errors: [],
149
+ warnings: [],
150
+ moduleTimings: new Map(),
151
+ };
152
+ }
133
153
  // ─── parseHvigorwOutput ─────────────────────────────────────────────────────
134
154
  export function parseHvigorwOutput(raw) {
135
155
  const stripped = stripAnsi(raw);
@@ -1,7 +1,7 @@
1
1
  import { TRACKED_EVENT_TYPES } from "../types.js";
2
2
  import { createSessionMetrics } from "./state.js";
3
3
  import { handlePartUpdated, handleSessionUpdated, handleMessageUpdated, handleSubAgentParts } from "./handlers.js";
4
- import { handleSessionIdle, flushMetrics, buildRoundSnapshot } from "../snapshot/flush.js";
4
+ import { handleSessionIdle, flushMetrics, buildRoundSnapshot, finalizeRoundWaits } from "../snapshot/flush.js";
5
5
  import { clearStepContentCache } from "../snapshot/steps.js";
6
6
  import { mergeChildMetrics } from "../snapshot/merge.js";
7
7
  import { resolveDirs } from "../dirs.js";
@@ -24,6 +24,70 @@ export function createMetricsEngine(opts = {}) {
24
24
  function resolveSessionId(rawId) {
25
25
  return childToParent.get(rawId) ?? rawId;
26
26
  }
27
+ /**
28
+ * 等待事件处理(0.4.1 活跃口径):
29
+ * - session.status 限流状态机:retry 记起点,busy/idle 结算本轮挂起时长并计数;
30
+ * - permission.asked/replied 配对:asked 的键是 `id`、replied 的键是 `requestID`
31
+ * (事件源字段名不对称),按 ID 配对差值入本轮;跨轮询问已在轮闭合时截断、条目不存。
32
+ * 父会话与子代理路由共用(子代理等待同样扣减父轮墙钟)。
33
+ */
34
+ function handleSessionWaits(state, event) {
35
+ const props = event.properties;
36
+ if (!props)
37
+ return;
38
+ if (event.type === "session.status") {
39
+ const statusType = (props.status?.type);
40
+ if (statusType === "retry") {
41
+ // 每次 retry 事件 = 一次限流重试(attempt 递增),直接计数;窗口起点只记首个
42
+ if (state._retryStartedAt === null)
43
+ state._retryStartedAt = Date.now();
44
+ state._roundRetryCount++;
45
+ state._checkpointDirty = true;
46
+ }
47
+ else if ((statusType === "busy" || statusType === "idle") && state._retryStartedAt !== null) {
48
+ // busy/idle 恢复:结算本轮挂起时长
49
+ state._roundRetryMs += Math.max(0, Date.now() - state._retryStartedAt);
50
+ state._retryStartedAt = null;
51
+ state._checkpointDirty = true;
52
+ }
53
+ return;
54
+ }
55
+ if (event.type === "permission.asked") {
56
+ const id = props.id || props.requestID || "";
57
+ if (id && !state._pendingPermissionAsks.has(id)) {
58
+ state._pendingPermissionAsks.set(id, { askedAt: Date.now(), roundStart: state._roundStartTime });
59
+ }
60
+ return;
61
+ }
62
+ if (event.type === "permission.replied") {
63
+ const id = props.requestID || props.id || "";
64
+ const pending = id ? state._pendingPermissionAsks.get(id) : undefined;
65
+ if (pending) {
66
+ // 同轮内答复才结算(跨轮的询问已在轮闭合截断入上轮,此处不再双计)
67
+ if (pending.roundStart === state._roundStartTime) {
68
+ state._roundPermissionMs += Math.max(0, Date.now() - pending.askedAt);
69
+ }
70
+ state._pendingPermissionAsks.delete(id);
71
+ }
72
+ }
73
+ }
74
+ /** 子代理 question 工具等待入账(父路径由 handlePartUpdated 累加,此处补子代理路由) */
75
+ function handleSubAgentQuestionWait(state, props) {
76
+ const part = props?.part;
77
+ if (!part || part.type !== "tool" || part.tool !== "question")
78
+ return;
79
+ const toolState = part.state;
80
+ if (toolState?.status !== "completed")
81
+ return;
82
+ const time = toolState.time;
83
+ const callID = part.callID;
84
+ if (!time?.start || !time?.end || !callID)
85
+ return;
86
+ if (state._questionCountedCallIds.has(callID))
87
+ return;
88
+ state._questionCountedCallIds.add(callID);
89
+ state._roundQuestionMs += time.end - time.start;
90
+ }
27
91
  function extractSessionId(event) {
28
92
  const props = event.properties;
29
93
  if (!props) {
@@ -92,6 +156,7 @@ export function createMetricsEngine(opts = {}) {
92
156
  function finalizeAndFlush(sessionId, state, now) {
93
157
  // 兜底补轮:退出/删除前最后一次交互往往等不到 session.idle(约 1 分钟静止判定),
94
158
  // 此处把未闭合的非空轮收掉(空轮返回 null,已 idle 过的轮累计已重置、同样为 null,天然防重)
159
+ finalizeRoundWaits(state, now);
95
160
  const pendingRound = buildRoundSnapshot(state, now);
96
161
  if (pendingRound)
97
162
  state.rounds.push(pendingRound);
@@ -189,6 +254,11 @@ export function createMetricsEngine(opts = {}) {
189
254
  if (childSessionIds.has(originalSessionId)) {
190
255
  const sub = state.subAgents.get(originalSessionId);
191
256
  if (sub) {
257
+ // 子代理的限流/权限/question 等待同样扣减父轮墙钟(0.4.1 活跃口径)
258
+ handleSessionWaits(state, event);
259
+ if (event.type === "message.part.updated") {
260
+ handleSubAgentQuestionWait(state, props);
261
+ }
192
262
  handleSubAgentParts(sub, event);
193
263
  // 子代理的完整回复/单步结束:置脏父会话
194
264
  if (event.type === "message.updated" ||
@@ -264,6 +334,8 @@ export function createMetricsEngine(opts = {}) {
264
334
  state.anomaly.events.push(event.type);
265
335
  }
266
336
  }
337
+ // 限流挂起/权限审批等待配对(0.4.1 活跃口径)
338
+ handleSessionWaits(state, event);
267
339
  // 统一的 checkpoint 判定点
268
340
  evaluateCheckpoint(sessionId, state);
269
341
  },
@@ -1,6 +1,7 @@
1
1
  import { createTokenUsage } from "../types.js";
2
- import { parseHvigorwOutput } from "../analysis/hvigor.js";
3
- import { buildRoundSnapshot, resetRoundState } from "../snapshot/flush.js";
2
+ import { parseHvigorwOutput, parseD2hWrapperOutput } from "../analysis/hvigor.js";
3
+ import { buildRoundSnapshot, resetRoundState, finalizeRoundWaits } from "../snapshot/flush.js";
4
+ import * as path from "node:path";
4
5
  // ─── Input Simplification ────────────────────────────────────────────────────
5
6
  function simplifyInput(raw) {
6
7
  if (!raw)
@@ -12,6 +13,29 @@ function simplifyInput(raw) {
12
13
  }
13
14
  return result;
14
15
  }
16
+ /**
17
+ * 收集代码行扫描的根候选:bash 工具的 workdir、write/edit 工具的文件目录。
18
+ * flush 时由 refreshCodeLineStats 汇总(连同会话工作目录)做工程根发现。
19
+ */
20
+ function recordCodeRootCandidate(state, tool, input) {
21
+ try {
22
+ if (tool === "bash") {
23
+ const workdir = input?.workdir;
24
+ if (typeof workdir === "string" && workdir)
25
+ state.codeRootCandidates.add(workdir);
26
+ return;
27
+ }
28
+ if (tool === "write" || tool === "edit") {
29
+ const filePath = input?.filePath || input?.path;
30
+ if (typeof filePath === "string" && filePath.includes(path.sep)) {
31
+ state.codeRootCandidates.add(path.dirname(filePath));
32
+ }
33
+ }
34
+ }
35
+ catch {
36
+ // 候选收集失败不影响主流程
37
+ }
38
+ }
15
39
  // ─── Invalid Tool Detection ──────────────────────────────────────────────────
16
40
  function isInvalidTool(part) {
17
41
  if (part.tool === "invalid")
@@ -109,6 +133,7 @@ function handlePartUpdated(state, props) {
109
133
  endMs: 0,
110
134
  parentMsgId,
111
135
  });
136
+ recordCodeRootCandidate(state, part.tool || "", simplifyInput(rawInput));
112
137
  }
113
138
  if (status === "completed" || status === "error") {
114
139
  const tc = existingEntry || {
@@ -130,7 +155,9 @@ function handlePartUpdated(state, props) {
130
155
  tc.outputPreview = typeof output === "string" ? output.slice(0, 500) : "";
131
156
  // Update input if available
132
157
  if (toolState?.input && Object.keys(toolState.input).length > 0) {
133
- Object.assign(tc.input, simplifyInput(toolState.input));
158
+ const mergedInput = simplifyInput(toolState.input);
159
+ Object.assign(tc.input, mergedInput);
160
+ recordCodeRootCandidate(state, tc.tool, mergedInput);
134
161
  }
135
162
  state.toolCallMap.set(callID, tc);
136
163
  }
@@ -150,6 +177,10 @@ function handlePartUpdated(state, props) {
150
177
  const duration = time && time.start && time.end
151
178
  ? (time.end - time.start)
152
179
  : 0;
180
+ // question 工具等待用户回答:time 窗口即等待时长(0.4.1 活跃口径扣减项)
181
+ if (toolName === "question" && time && time.start && time.end) {
182
+ state._roundQuestionMs += time.end - time.start;
183
+ }
153
184
  // Update distribution
154
185
  const stats = state.tools.distribution.get(toolName) ?? { calls: 0, errors: 0, totalDuration: 0, maxDuration: 0 };
155
186
  stats.calls++;
@@ -173,11 +204,17 @@ function handlePartUpdated(state, props) {
173
204
  // Compile stats extraction (with dedup)
174
205
  if (part.tool === "bash") {
175
206
  const command = state.toolCallMap.get(callID)?.input?.command || "";
176
- if (command.includes("hvigorw")) {
207
+ // droid2hmos 包装器(build/test harmony)内部执行 hvigorw:命令行不含
208
+ // "hvigorw"、输出只有状态摘要(hvigor 原始输出被重定向到日志文件),
209
+ // 按命令形态识别为 HarmonyOS 构建调用
210
+ const isD2hHarmonyBuild = /\bdroid2hmos(?:\.(?:cmd|exe|bat|ps1))?\s+(?:build|test)\s+harmony\b/.test(command);
211
+ if (command.includes("hvigorw") || isD2hHarmonyBuild) {
177
212
  if (status === "completed" && !alreadyCompleted) {
178
213
  state.compileStats.hvigorwCalls++;
179
214
  const output = toolState?.output || "";
180
- const result = parseHvigorwOutput(output);
215
+ const result = command.includes("hvigorw")
216
+ ? parseHvigorwOutput(output)
217
+ : parseD2hWrapperOutput(output);
181
218
  state.compileStats.lastBuildSuccess = result.success;
182
219
  // First build per round
183
220
  if (!state._roundFirstBuildTracked) {
@@ -252,16 +289,8 @@ function handlePartUpdated(state, props) {
252
289
  }
253
290
  }
254
291
  }
255
- if (part.tool === "write" && !alreadyCompleted) {
256
- const entry = state.toolCallMap.get(callID);
257
- const filePath = entry?.input?.filePath || entry?.input?.path || "";
258
- if (filePath.endsWith(".ets") && status === "completed") {
259
- const content = entry?.input?.content;
260
- if (content) {
261
- state.compileStats.etsLines += content.split("\n").filter(l => l.length > 0).length;
262
- }
263
- }
264
- }
292
+ // (0.4.2 etsLines 等代码行维度改由 flush 时工程全量扫描产出,
293
+ // 不再对 write 工具内容做累计——见 analysis/codelines.ts 口径说明)
265
294
  // Fix cycle: count code changes
266
295
  if ((part.tool === "write" || part.tool === "edit") && status === "completed") {
267
296
  if (state.compileStats._pendingFix) {
@@ -477,6 +506,7 @@ function handleMessageUpdated(state, props) {
477
506
  // 新用户消息 = 天然轮边界:先收掉当前非空轮(不再等 idle 静止判定),
478
507
  // 快速连发多条不再被合并进同一轮;resume 重放期间轮累计为空,
479
508
  // buildRoundSnapshot 返回 null 无副作用,重放结束后首条真实消息正常开新轮
509
+ finalizeRoundWaits(state, Date.now());
480
510
  const round = buildRoundSnapshot(state, Date.now());
481
511
  if (round) {
482
512
  state.rounds.push(round);
@@ -1,4 +1,5 @@
1
1
  import type { MessageEntry, PlanningCall, RoundSnapshot, SkillCallEntry, SkillSearchEntry, TokenUsage, ToolCallEntry, SessionMeta, CompileStats, StageInfo, ToolStats } from "../types.js";
2
+ import type { CodeLineStats } from "../analysis/codelines.js";
2
3
  export interface SubAgentState {
3
4
  sessionId: string;
4
5
  agentName: string;
@@ -113,6 +114,23 @@ export interface SessionMetricsState {
113
114
  _roundTokens: TokenUsage;
114
115
  _roundToolCalls: number;
115
116
  _roundErrors: number;
117
+ /** 本轮 question 工具等待用户累计(ms,0.4.1 活跃口径) */
118
+ _roundQuestionMs: number;
119
+ /** 本轮权限审批等待累计(ms) */
120
+ _roundPermissionMs: number;
121
+ /** 本轮限流重试挂起累计(ms) */
122
+ _roundRetryMs: number;
123
+ /** 本轮限流重试次数 */
124
+ _roundRetryCount: number;
125
+ /** 已问未答的权限请求:requestID → 询问时刻与所属轮起点(轮闭合时截断结算后移除) */
126
+ _pendingPermissionAsks: Map<string, {
127
+ askedAt: number;
128
+ roundStart: number;
129
+ }>;
130
+ /** 限流挂起起点(session.status retry 起始时刻);null = 未挂起 */
131
+ _retryStartedAt: number | null;
132
+ /** 已入账 question 等待的 callID(子代理路径去重用;父路径由 toolCallMap 去重) */
133
+ _questionCountedCallIds: Set<string>;
116
134
  _roundFirstBuildTracked: boolean;
117
135
  _roundIdleProcessed: boolean;
118
136
  _idleFlushed: boolean;
@@ -120,6 +138,14 @@ export interface SessionMetricsState {
120
138
  _checkpointDirty: boolean;
121
139
  /** 上次 flush 的累加字段基线;null = 尚未 flush 过 */
122
140
  _flushBaseline: FlushBaseline | null;
141
+ /** 最近一次扫描结果(flush 时按 30s 节流刷新;见 flush.ts refreshCodeLineStats) */
142
+ codeLines: CodeLineStats;
143
+ /** 根候选:bash workdir 与 write/edit 文件目录(工作目录取 sessionMeta) */
144
+ codeRootCandidates: Set<string>;
145
+ /** 最近一次代码行扫描时刻(ms) */
146
+ _lastCodeScanAt: number;
147
+ /** 最近一次扫描时的候选集合签名(变化即重扫) */
148
+ _codeScanRootsKey: string;
123
149
  messageMap: Map<string, MessageEntry>;
124
150
  toolCallMap: Map<string, ToolCallEntry>;
125
151
  textLengthMap: Map<string, number>;
@@ -50,7 +50,6 @@ export function createSessionMetrics(sessionId) {
50
50
  hvigorwCalls: 0,
51
51
  hvigorwErrors: 0,
52
52
  lastBuildSuccess: false,
53
- etsLines: 0,
54
53
  firstBuildPerRound: [],
55
54
  errorCodes: new Map(),
56
55
  fixCycles: [],
@@ -58,6 +57,10 @@ export function createSessionMetrics(sessionId) {
58
57
  warnings: new Map(),
59
58
  moduleTimings: new Map(),
60
59
  },
60
+ codeLines: { etsLines: 0, cLines: 0, cppLines: 0, testLines: 0, businessCodeLines: 0 },
61
+ codeRootCandidates: new Set(),
62
+ _lastCodeScanAt: 0,
63
+ _codeScanRootsKey: "",
61
64
  orderCounter: 0,
62
65
  skillCallMap: new Map(),
63
66
  htmlPreviewMsgId: null,
@@ -75,6 +78,13 @@ export function createSessionMetrics(sessionId) {
75
78
  _roundTokens: createTokenUsage(),
76
79
  _roundToolCalls: 0,
77
80
  _roundErrors: 0,
81
+ _roundQuestionMs: 0,
82
+ _roundPermissionMs: 0,
83
+ _roundRetryMs: 0,
84
+ _roundRetryCount: 0,
85
+ _pendingPermissionAsks: new Map(),
86
+ _retryStartedAt: null,
87
+ _questionCountedCallIds: new Set(),
78
88
  _roundFirstBuildTracked: false,
79
89
  _roundIdleProcessed: false,
80
90
  _idleFlushed: false,
@@ -2,15 +2,12 @@ import type { MetricsDirs } from "../dirs.js";
2
2
  import type { SessionMetricsState } from "../engine/state.js";
3
3
  import type { RoundSnapshot, MetricsOutput } from "../types.js";
4
4
  /**
5
- * 把会话当前状态落盘为快照文件(读旧文件 → 合并 → 原子覆盖写)。
6
- * 可重复调用,不会造成数据重复累计。
5
+ * 刷新会话的工程代码行多维统计(30s 节流;候选根集合变化立即重扫)。
6
+ * 扫描口径见 analysis/codelines.ts;找不到鸿蒙工程根时全维度保持 0。
7
7
  *
8
- * @param sessionId 会话 id,快照文件名为 `<sessionId>.json`
9
- * @param state 会话状态,写盘成功后原地刷新差量基线
10
- * @param now 本次快照的结束时间(ms)
11
- * @param dirs 输出目录;缺省用进程级默认目录
12
- * @returns 本次由内存态构建的快照(未与旧文件合并的那份)
8
+ * @param state 会话状态(结果原地写入 state.codeLines)
13
9
  */
10
+ export declare function refreshCodeLineStats(state: SessionMetricsState): void;
14
11
  declare function flushMetrics(sessionId: string, state: SessionMetricsState, now: number, dirs?: Partial<MetricsDirs>): MetricsOutput;
15
12
  /**
16
13
  * 处理一轮会话结束:保存本轮 round 快照并落盘。
@@ -19,6 +16,15 @@ declare function flushMetrics(sessionId: string, state: SessionMetricsState, now
19
16
  * @param sessionId 会话 id
20
17
  * @param dirs 输出目录;缺省用进程级默认目录
21
18
  */
19
+ /**
20
+ * 轮闭合前的等待结算(0.4.1 活跃口径):把仍挂起的 question/permission/retry
21
+ * 截断入本轮累加器,并把挂起计时重锚到闭合时刻——等待若延续到下一轮,
22
+ * 只在新轮计尾段,跨轮拆分总量不丢、不双计。
23
+ *
24
+ * @param state 会话状态(就地结算累加器与挂起计时)
25
+ * @param now 轮闭合时刻(ms)
26
+ */
27
+ export declare function finalizeRoundWaits(state: SessionMetricsState, now: number): void;
22
28
  /**
23
29
  * 从当前轮累计态构建轮快照;空轮(无 token 且无工具调用)返回 null(天然防重)。
24
30
  *
@@ -26,7 +32,7 @@ declare function flushMetrics(sessionId: string, state: SessionMetricsState, now
26
32
  * @param now 结算时刻(ms)
27
33
  */
28
34
  export declare function buildRoundSnapshot(state: SessionMetricsState, now: number): RoundSnapshot | null;
29
- /** 重置轮累计态,开始新轮(不改动 stage 相关状态)。 */
35
+ /** 重置轮累计态,开始新轮(不改动 stage 相关状态;等待挂起计时由 finalizeRoundWaits 结算)。 */
30
36
  export declare function resetRoundState(state: SessionMetricsState, now: number): void;
31
37
  declare function handleSessionIdle(state: SessionMetricsState, sessionId: string, dirs?: Partial<MetricsDirs>): MetricsOutput | undefined;
32
38
  export { flushMetrics, handleSessionIdle };
@@ -5,6 +5,7 @@ import { createTokenUsage } from "../types.js";
5
5
  import { getMetricsDir } from "../dirs.js";
6
6
  import { buildSteps, extractStepContent } from "./steps.js";
7
7
  import { mergeMetricsOutput } from "./merge.js";
8
+ import { discoverHarmonyRoots, scanCodeLines } from "../analysis/codelines.js";
8
9
  function filterByKeys(source, keys) {
9
10
  const result = new Map();
10
11
  for (const key of keys) {
@@ -24,7 +25,40 @@ function filterByKeys(source, keys) {
24
25
  * @param dirs 输出目录;缺省用进程级默认目录
25
26
  * @returns 本次由内存态构建的快照(未与旧文件合并的那份)
26
27
  */
28
+ /** 代码行扫描节流间隔:活跃会话 checkpoint 频率远高于此,避免重复扫盘 */
29
+ const CODE_SCAN_THROTTLE_MS = 30_000;
30
+ /**
31
+ * 刷新会话的工程代码行多维统计(30s 节流;候选根集合变化立即重扫)。
32
+ * 扫描口径见 analysis/codelines.ts;找不到鸿蒙工程根时全维度保持 0。
33
+ *
34
+ * @param state 会话状态(结果原地写入 state.codeLines)
35
+ */
36
+ export function refreshCodeLineStats(state) {
37
+ try {
38
+ const candidates = [state.sessionMeta.workingDirectory, ...state.codeRootCandidates]
39
+ .filter((dir) => typeof dir === "string" && dir.length > 0);
40
+ const rootsKey = [...candidates]
41
+ .map((dir) => (process.platform === "win32" ? dir.toLowerCase() : dir))
42
+ .sort()
43
+ .join("|");
44
+ const now = Date.now();
45
+ if (state._lastCodeScanAt > 0 &&
46
+ now - state._lastCodeScanAt < CODE_SCAN_THROTTLE_MS &&
47
+ state._codeScanRootsKey === rootsKey) {
48
+ return;
49
+ }
50
+ const roots = discoverHarmonyRoots(candidates, state.startTime);
51
+ state.codeLines = scanCodeLines(roots);
52
+ state._lastCodeScanAt = now;
53
+ state._codeScanRootsKey = rootsKey;
54
+ }
55
+ catch {
56
+ // 扫描失败保持上次结果(或全 0),不影响快照其余字段
57
+ }
58
+ }
27
59
  function flushMetrics(sessionId, state, now, dirs) {
60
+ // 代码行多维统计(节流扫描,见 refreshCodeLineStats)
61
+ refreshCodeLineStats(state);
28
62
  // Finalize agent usage for current agent
29
63
  if (state.agent.current) {
30
64
  const elapsed = now - state.agent.lastSwitchTime;
@@ -36,6 +70,16 @@ function flushMetrics(sessionId, state, now, dirs) {
36
70
  }
37
71
  const endTime = now;
38
72
  const duration = endTime - state.startTime;
73
+ // AI 活跃统计(0.4.1 起):Σ 已闭合轮 activeDuration + 等待明细(进行中的开轮不计,闭合后下次 flush 补齐)
74
+ const aiActiveDuration = state.rounds.reduce((sum, r) => sum + (r.activeDuration ?? r.duration ?? 0), 0);
75
+ const roundsDuration = state.rounds.reduce((sum, r) => sum + (r.duration || 0), 0);
76
+ const waitBreakdown = {
77
+ questionMs: state.rounds.reduce((sum, r) => sum + (r.questionMs ?? 0), 0),
78
+ permissionMs: state.rounds.reduce((sum, r) => sum + (r.permissionMs ?? 0), 0),
79
+ retryMs: state.rounds.reduce((sum, r) => sum + (r.retryMs ?? 0), 0),
80
+ retryCount: state.rounds.reduce((sum, r) => sum + (r.retryCount ?? 0), 0),
81
+ otherIdleMs: Math.max(0, duration - roundsDuration),
82
+ };
39
83
  // Calculate cache hit rate
40
84
  const denom = state.tokens.input + state.tokens.cacheRead;
41
85
  const cacheHitRate = denom > 0 ? state.tokens.cacheRead / denom : 0;
@@ -223,6 +267,9 @@ function flushMetrics(sessionId, state, now, dirs) {
223
267
  startTime: state.startTime,
224
268
  endTime,
225
269
  duration,
270
+ aiActiveDuration,
271
+ aiActiveRatio: duration > 0 ? aiActiveDuration / duration : 0,
272
+ waitBreakdown,
226
273
  systemPrompts,
227
274
  rounds: state.rounds,
228
275
  tokens: (() => {
@@ -260,7 +307,11 @@ function flushMetrics(sessionId, state, now, dirs) {
260
307
  })),
261
308
  header,
262
309
  codeStats: {
263
- etsLines: state.compileStats.etsLines,
310
+ etsLines: state.codeLines.etsLines,
311
+ cLines: state.codeLines.cLines,
312
+ cppLines: state.codeLines.cppLines,
313
+ testLines: state.codeLines.testLines,
314
+ businessCodeLines: state.codeLines.businessCodeLines,
264
315
  buildSuccess: state.compileStats.lastBuildSuccess,
265
316
  fixCompileCount: state.compileStats.hvigorwCalls,
266
317
  totalCompileErrors: state.compileStats.hvigorwErrors,
@@ -394,6 +445,33 @@ function flushMetrics(sessionId, state, now, dirs) {
394
445
  * @param sessionId 会话 id
395
446
  * @param dirs 输出目录;缺省用进程级默认目录
396
447
  */
448
+ /**
449
+ * 轮闭合前的等待结算(0.4.1 活跃口径):把仍挂起的 question/permission/retry
450
+ * 截断入本轮累加器,并把挂起计时重锚到闭合时刻——等待若延续到下一轮,
451
+ * 只在新轮计尾段,跨轮拆分总量不丢、不双计。
452
+ *
453
+ * @param state 会话状态(就地结算累加器与挂起计时)
454
+ * @param now 轮闭合时刻(ms)
455
+ */
456
+ export function finalizeRoundWaits(state, now) {
457
+ // 未答的权限询问:截断入本轮并移除(后续 replied 到达时无条目即忽略,防跨轮双计)
458
+ for (const [id, pending] of state._pendingPermissionAsks) {
459
+ state._roundPermissionMs += Math.max(0, now - pending.askedAt);
460
+ state._pendingPermissionAsks.delete(id);
461
+ }
462
+ // 挂起中的限流重试:截断入本轮,起点重锚(计数已在 retry 事件时入账,此处只补时长)
463
+ if (state._retryStartedAt !== null) {
464
+ state._roundRetryMs += Math.max(0, now - state._retryStartedAt);
465
+ state._retryStartedAt = now;
466
+ }
467
+ // 仍在等待回答的 question 工具:截断入本轮,startMs 重锚(completed 事件只补尾段)
468
+ for (const tc of state.toolCallMap.values()) {
469
+ if (tc.tool === "question" && tc.status === "running" && tc.startMs > 0 && tc.startMs < now) {
470
+ state._roundQuestionMs += now - tc.startMs;
471
+ tc.startMs = now;
472
+ }
473
+ }
474
+ }
397
475
  /**
398
476
  * 从当前轮累计态构建轮快照;空轮(无 token 且无工具调用)返回 null(天然防重)。
399
477
  *
@@ -401,16 +479,34 @@ function flushMetrics(sessionId, state, now, dirs) {
401
479
  * @param now 结算时刻(ms)
402
480
  */
403
481
  export function buildRoundSnapshot(state, now) {
404
- const isEmptyRound = state._roundTokens.total === 0 && state._roundToolCalls === 0;
482
+ // 空轮判定:无 token、无工具调用且无任何等待(限流/审批/提问)才视为空轮丢弃。
483
+ // 仅含等待的轮(如纯限流挂起)仍占用墙钟,须保留以维持 activeDuration 扣减恒等式。
484
+ const isEmptyRound = state._roundTokens.total === 0
485
+ && state._roundToolCalls === 0
486
+ && state._roundQuestionMs === 0
487
+ && state._roundPermissionMs === 0
488
+ && state._roundRetryMs === 0
489
+ && state._roundRetryCount === 0;
405
490
  if (isEmptyRound)
406
491
  return null;
407
492
  const firstTokenLatency = state._hasTextPart && state._firstEventTime > 0
408
493
  ? state._firstTextTime - state._firstEventTime
409
494
  : 0;
495
+ const duration = now - state._roundStartTime;
496
+ // 活跃口径(0.4.1 起):轮墙钟减已知等待,各项 clamp 到 [0, duration],合计下限 0
497
+ const clampWait = (v) => Math.max(0, Math.min(v, duration));
498
+ const questionMs = clampWait(state._roundQuestionMs);
499
+ const permissionMs = clampWait(state._roundPermissionMs);
500
+ const retryMs = clampWait(state._roundRetryMs);
410
501
  return {
411
502
  startTime: state._roundStartTime,
412
503
  roundIndex: state.rounds.length,
413
- duration: now - state._roundStartTime,
504
+ duration,
505
+ activeDuration: Math.max(0, duration - questionMs - permissionMs - retryMs),
506
+ questionMs,
507
+ permissionMs,
508
+ retryMs,
509
+ retryCount: state._roundRetryCount,
414
510
  firstTokenLatency,
415
511
  tokens: { ...state._roundTokens },
416
512
  toolCalls: state._roundToolCalls,
@@ -418,7 +514,7 @@ export function buildRoundSnapshot(state, now) {
418
514
  userMessage: state._pendingUserMessage.length > 0 ? [...state._pendingUserMessage] : undefined,
419
515
  };
420
516
  }
421
- /** 重置轮累计态,开始新轮(不改动 stage 相关状态)。 */
517
+ /** 重置轮累计态,开始新轮(不改动 stage 相关状态;等待挂起计时由 finalizeRoundWaits 结算)。 */
422
518
  export function resetRoundState(state, now) {
423
519
  state._roundStartTime = now;
424
520
  state._firstEventTime = 0;
@@ -428,6 +524,10 @@ export function resetRoundState(state, now) {
428
524
  state._roundTokens = createTokenUsage();
429
525
  state._roundToolCalls = 0;
430
526
  state._roundErrors = 0;
527
+ state._roundQuestionMs = 0;
528
+ state._roundPermissionMs = 0;
529
+ state._roundRetryMs = 0;
530
+ state._roundRetryCount = 0;
431
531
  state._pendingUserMessage = [];
432
532
  state._roundFirstBuildTracked = false;
433
533
  }
@@ -437,6 +537,7 @@ function handleSessionIdle(state, sessionId, dirs) {
437
537
  if (state._roundIdleProcessed) {
438
538
  return;
439
539
  }
540
+ finalizeRoundWaits(state, now);
440
541
  const round = buildRoundSnapshot(state, now);
441
542
  if (round)
442
543
  state.rounds.push(round);
@@ -57,7 +57,6 @@ function mergeChildMetrics(parent, child, meta) {
57
57
  // Merge compileStats (additive)
58
58
  parent.compileStats.hvigorwCalls += child.compileStats.hvigorwCalls;
59
59
  parent.compileStats.hvigorwErrors += child.compileStats.hvigorwErrors;
60
- parent.compileStats.etsLines += child.compileStats.etsLines;
61
60
  // lastBuildSuccess: take child's value if child had any builds
62
61
  if (child.compileStats.hvigorwCalls > 0 || child.compileStats.hvigorwErrors > 0) {
63
62
  parent.compileStats.lastBuildSuccess = child.compileStats.lastBuildSuccess;
@@ -207,6 +206,18 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
207
206
  ...fresh.rounds.filter(r => !existingRoundIndices.has(r.roundIndex)),
208
207
  ].sort((a, b) => a.roundIndex - b.roundIndex);
209
208
  })();
209
+ // AI 活跃统计(0.4.1 起):从合并后的轮数组重算——fresh 重启后可能只含新段,
210
+ // 不能直接展开 fresh 汇总;旧轮(0.4.0 快照)缺 activeDuration 回退按 duration 计(视为全活跃),
211
+ // 等待明细缺省按 0。与 planning 同思路:合并方从合并后明细重新推导。
212
+ const aiActiveDuration = rounds.reduce((sum, r) => sum + (r.activeDuration ?? r.duration ?? 0), 0);
213
+ const roundsDuration = rounds.reduce((sum, r) => sum + (r.duration || 0), 0);
214
+ const waitBreakdown = {
215
+ questionMs: rounds.reduce((sum, r) => sum + (r.questionMs ?? 0), 0),
216
+ permissionMs: rounds.reduce((sum, r) => sum + (r.permissionMs ?? 0), 0),
217
+ retryMs: rounds.reduce((sum, r) => sum + (r.retryMs ?? 0), 0),
218
+ retryCount: rounds.reduce((sum, r) => sum + (r.retryCount ?? 0), 0),
219
+ otherIdleMs: Math.max(0, duration - roundsDuration),
220
+ };
210
221
  // Steps:进程重启后 orderCounter 从 0 重编,fresh 的 index 与磁盘 existing 撞车,
211
222
  // 且时间上更晚的新步骤被排到列表头部;resume 重灌轮的时间戳全新,index 与时间戳都无法跨进程关联。
212
223
  // 统一策略:按 startTime:endTime 去重(防同进程重放,fresh wins)后,
@@ -494,18 +505,24 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
494
505
  };
495
506
  })();
496
507
  // codeStats 顶层分两类处理:累计型(fixCompileCount/totalCompileErrors)按差量并入;
497
- // 快照型(etsLines/buildSuccess/firstBuildPassRate/firstBuildPerRound)在 fresh 无构建迹象
498
- // (全零/空)时保留 existing——重启进程没跑过 hvigorw,fresh 全零是"不知道"而非"清零"。
508
+ // 快照型(代码行五维/buildSuccess/firstBuildPassRate/firstBuildPerRound)在 fresh
509
+ // 有值(>0)时取 fresh——工程全量扫描的时效值,fresh 无值保留 existing
510
+ // (重启进程未扫到工程是"不知道"而非"清零")。
499
511
  const fixCompileCount = existing.codeStats.fixCompileCount
500
512
  + Math.max(0, fresh.codeStats.fixCompileCount - (baseline?.fixCompileCount ?? 0));
501
513
  const totalCompileErrors = existing.codeStats.totalCompileErrors
502
514
  + Math.max(0, fresh.codeStats.totalCompileErrors - (baseline?.totalCompileErrors ?? 0));
503
- const freshHasBuild = fresh.codeStats.etsLines > 0
515
+ const freshHasBuild = fresh.codeStats.businessCodeLines > 0
504
516
  || fresh.codeStats.fixCompileCount > (baseline?.fixCompileCount ?? 0)
505
517
  || totalCompileErrors > existing.codeStats.totalCompileErrors
506
518
  || fresh.codeStats.firstBuildPerRound.length > 0;
519
+ const pickLineCount = (freshValue, existingValue) => (freshValue ?? 0) > 0 ? freshValue : (existingValue ?? 0);
507
520
  const codeStats = {
508
- etsLines: fresh.codeStats.etsLines > 0 ? fresh.codeStats.etsLines : existing.codeStats.etsLines,
521
+ etsLines: pickLineCount(fresh.codeStats.etsLines, existing.codeStats.etsLines),
522
+ cLines: pickLineCount(fresh.codeStats.cLines, existing.codeStats.cLines),
523
+ cppLines: pickLineCount(fresh.codeStats.cppLines, existing.codeStats.cppLines),
524
+ testLines: pickLineCount(fresh.codeStats.testLines, existing.codeStats.testLines),
525
+ businessCodeLines: pickLineCount(fresh.codeStats.businessCodeLines, existing.codeStats.businessCodeLines),
509
526
  buildSuccess: freshHasBuild ? fresh.codeStats.buildSuccess : existing.codeStats.buildSuccess,
510
527
  fixCompileCount,
511
528
  totalCompileErrors,
@@ -526,6 +543,9 @@ export function mergeMetricsOutput(existing, fresh, baseline) {
526
543
  startTime,
527
544
  endTime: fresh.endTime,
528
545
  duration,
546
+ aiActiveDuration,
547
+ aiActiveRatio: duration > 0 ? aiActiveDuration / duration : 0,
548
+ waitBreakdown,
529
549
  systemPrompts,
530
550
  rounds,
531
551
  tokens,
@@ -85,7 +85,6 @@ export interface CompileStats {
85
85
  hvigorwCalls: number;
86
86
  hvigorwErrors: number;
87
87
  lastBuildSuccess: boolean;
88
- etsLines: number;
89
88
  firstBuildPerRound: boolean[];
90
89
  errorCodes: Map<string, {
91
90
  count: number;
@@ -163,12 +162,35 @@ export interface RoundSnapshot {
163
162
  startTime?: number;
164
163
  roundIndex: number;
165
164
  duration: number;
165
+ /** AI 活跃时长(0.4.1 起)= duration − questionMs − permissionMs − retryMs(下限 0);旧快照缺省,合并回退按 duration 计 */
166
+ activeDuration?: number;
167
+ /** question 工具等待用户回答累计(ms,0.4.1 起) */
168
+ questionMs?: number;
169
+ /** 权限审批等待累计(ms,0.4.1 起) */
170
+ permissionMs?: number;
171
+ /** 限流重试挂起累计(ms,0.4.1 起) */
172
+ retryMs?: number;
173
+ /** 限流重试次数(0.4.1 起) */
174
+ retryCount?: number;
166
175
  firstTokenLatency: number;
167
176
  tokens: TokenUsage;
168
177
  toolCalls: number;
169
178
  errors: number;
170
179
  userMessage?: string[];
171
180
  }
181
+ /** 会话级等待扣减明细(0.4.1 起):duration = aiActiveDuration + 各等待项之和 */
182
+ export interface WaitBreakdown {
183
+ /** question 工具等待用户回答累计(ms) */
184
+ questionMs: number;
185
+ /** 权限审批等待累计(ms) */
186
+ permissionMs: number;
187
+ /** 限流重试挂起累计(ms) */
188
+ retryMs: number;
189
+ /** 限流重试次数 */
190
+ retryCount: number;
191
+ /** 轮间空闲:墙钟未被任何已闭合轮覆盖的部分(ms) */
192
+ otherIdleMs: number;
193
+ }
172
194
  export interface SubAgentOutput {
173
195
  sessionId: string;
174
196
  agent: string;
@@ -201,6 +223,12 @@ export interface MetricsOutput {
201
223
  startTime: number;
202
224
  endTime: number;
203
225
  duration: number;
226
+ /** AI 活跃时长(0.4.1 起):Σ 轮 activeDuration(轮墙钟减去已知等待,step 间死时间计入活跃) */
227
+ aiActiveDuration?: number;
228
+ /** AI 活跃占比 = aiActiveDuration / duration(0-1,0.4.1 起) */
229
+ aiActiveRatio?: number;
230
+ /** 等待扣减明细(0.4.1 起) */
231
+ waitBreakdown?: WaitBreakdown;
204
232
  systemPrompts: Record<string, string[]>;
205
233
  rounds: RoundSnapshot[];
206
234
  tokens: TokenUsage & {
@@ -235,7 +263,16 @@ export interface MetricsOutput {
235
263
  }>;
236
264
  header: SessionMeta;
237
265
  codeStats: {
266
+ /** 排除测试的 .ets 行数(0.4.2 起为工程全量扫描口径,非 write 工具累计) */
238
267
  etsLines: number;
268
+ /** 排除测试的 C(.c/.h)行数 */
269
+ cLines: number;
270
+ /** 排除测试的 C++(.cpp/.cc/.cxx/.hpp 等)行数 */
271
+ cppLines: number;
272
+ /** 测试代码行数(ohosTest、test 等目录子树与 .test.、.spec. 命名文件,任意语言) */
273
+ testLines: number;
274
+ /** 实际业务代码总量 = ets + C + C++ + 测试(= 全量;构建/配置文件不计入) */
275
+ businessCodeLines: number;
239
276
  buildSuccess: boolean;
240
277
  fixCompileCount: number;
241
278
  totalCompileErrors: number;
@@ -17,6 +17,8 @@ const REGION_DOMAINS = {
17
17
  const TOKEN_MARGIN_SECONDS = 60;
18
18
  const TOKEN_TIMEOUT_MS = 30_000;
19
19
  const UPLOAD_TIMEOUT_MS = 10 * 60_000;
20
+ /** 上传 UA:Node 原生 http.request 默认不发送 User-Agent,AGC 存储已开始强制校验(2026-09-16 起 400) */
21
+ const UPLOAD_USER_AGENT = "opencode-metrics-plugin/0.4.3";
20
22
  /** AGC 凭证默认文件:包内 assets/agc-apiclient.json(opencode.json 未配置 agcConfigPath 时使用) */
21
23
  export function defaultAgcConfigPath() {
22
24
  return fileURLToPath(new URL("../../../assets/agc-apiclient.json", import.meta.url));
@@ -172,6 +174,7 @@ async function uploadFile(credentials, token, localPath, remoteObject) {
172
174
  productId: credentials.project_id,
173
175
  Authorization: `Bearer ${token}`,
174
176
  "Content-Type": "application/octet-stream",
177
+ "User-Agent": UPLOAD_USER_AGENT,
175
178
  "X-Agc-File-Size": String(sizeBytes),
176
179
  "Content-Length": String(sizeBytes),
177
180
  },
@@ -6,7 +6,9 @@ const BUILTIN_SCENARIOS = {
6
6
  events: ["dispose", "session.deleted"],
7
7
  tools: ["task"],
8
8
  },
9
- "git-commit": { label: "git", commands: ["\\bgit\\s+commit\\b"] },
9
+ // gitcommit 之间允许 ≤4 个选项(如 -c user.name=x、-C <path>、--no-verify),
10
+ // 但选项必须以 - 开头——避免把 "git log --grep=commit" 一类误判为提交
11
+ "git-commit": { label: "git", commands: ["\\bgit\\s+(?:-{1,2}[\\w.-]+(?:[ =][^\\s]+)?\\s+){0,4}commit\\b"] },
10
12
  };
11
13
  /**
12
14
  * 合并内置场景与 opencode.json 配置:内置 key 深合并(label/commands/events/tools
@@ -1,5 +1,5 @@
1
1
  import type { MetricsDirs } from "../dirs.js";
2
- import type { UploadConfig } from "./types.js";
2
+ import type { UploadConfig, UploadPackage } from "./types.js";
3
3
  export interface UploadManagerOptions {
4
4
  config?: UploadConfig;
5
5
  /** 实例目录(metricsDir/eventsDir/uploadStagingDir) */
@@ -23,6 +23,21 @@ export interface UploadManager {
23
23
  * @param options 配置与目录
24
24
  */
25
25
  export declare function createUploadManager(options: UploadManagerOptions): UploadManager | null;
26
+ /**
27
+ * 启动清扫暂存目录顶层:崩溃遗留的完整三件套(tar.gz + json + meta.json)
28
+ * 移入 failed/ 同构目录并入队重传;孤立文件与组装目录直接删除。
29
+ *
30
+ * 两遍扫描:第一遍只处理 .tar.gz(三件套校验 → rescue 或删 tar.gz,并标记
31
+ * 三个文件名已处理)。字母序下 .json/.meta.json 先于 .tar.gz 迭代,若单遍
32
+ * 边迭代边删 .json,会在 tar.gz 校验前破坏三件套——完整崩溃遗留包必然被
33
+ * 误判"不完整"而销毁(曾连续销毁终态包)。第二遍才清理无 tar.gz 对应的
34
+ * 孤儿 .json/meta 与组装残留。
35
+ *
36
+ * @param stagingDir 暂存目录
37
+ * @param rescueEnabled 失败保留开关(false 时完整三件套也直接删除,旧行为)
38
+ * @returns 可补偿重传的包列表(顶层遗留部分)
39
+ */
40
+ export declare function sweepStagingDirectory(stagingDir: string, rescueEnabled: boolean): UploadPackage[];
26
41
  /**
27
42
  * 记录会话终态包已成功上云。
28
43
  * 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
@@ -41,7 +56,10 @@ export interface OrphanSnapshot {
41
56
  }
42
57
  /**
43
58
  * 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
44
- * 上传成功后快照即被删除,"文件仍在"天然等价于"终态未传出"。
59
+ * 快照原件永久保留为本地记录,"已传标记"是防重复上传的依据。
60
+ * 时效判断:标记只证明"标记时刻之前的快照已上云"——会话在中途终态触发
61
+ * (task 完成/session.deleted)上传后仍可能继续运行并更新快照,因此快照
62
+ * mtime 晚于标记时间(超出 60s 缓冲)仍视为孤儿,补传最终态。
45
63
  *
46
64
  * @param metricsDir 快照目录
47
65
  * @param idleMs 静默下限(毫秒)
@@ -353,11 +353,17 @@ function dedupePaths(paths) {
353
353
  * 启动清扫暂存目录顶层:崩溃遗留的完整三件套(tar.gz + json + meta.json)
354
354
  * 移入 failed/ 同构目录并入队重传;孤立文件与组装目录直接删除。
355
355
  *
356
+ * 两遍扫描:第一遍只处理 .tar.gz(三件套校验 → rescue 或删 tar.gz,并标记
357
+ * 三个文件名已处理)。字母序下 .json/.meta.json 先于 .tar.gz 迭代,若单遍
358
+ * 边迭代边删 .json,会在 tar.gz 校验前破坏三件套——完整崩溃遗留包必然被
359
+ * 误判"不完整"而销毁(曾连续销毁终态包)。第二遍才清理无 tar.gz 对应的
360
+ * 孤儿 .json/meta 与组装残留。
361
+ *
356
362
  * @param stagingDir 暂存目录
357
363
  * @param rescueEnabled 失败保留开关(false 时完整三件套也直接删除,旧行为)
358
364
  * @returns 可补偿重传的包列表(顶层遗留部分)
359
365
  */
360
- function sweepStagingDirectory(stagingDir, rescueEnabled) {
366
+ export function sweepStagingDirectory(stagingDir, rescueEnabled) {
361
367
  const rescued = [];
362
368
  try {
363
369
  const entries = fs.readdirSync(stagingDir);
@@ -365,26 +371,31 @@ function sweepStagingDirectory(stagingDir, rescueEnabled) {
365
371
  const handled = new Set(["failed"]);
366
372
  let removed = 0;
367
373
  const removedNames = [];
374
+ // 第一遍:.tar.gz 三件套判定(必须先于 .json 清理,见函数注释)
368
375
  for (const entry of entries) {
369
376
  if (handled.has(entry))
370
377
  continue;
371
- if (entry.endsWith(".tar.gz")) {
372
- const base = entry.slice(0, -".tar.gz".length);
373
- const pkg = rescueEnabled ? loadIntactPackage(stagingDir, base, names) : undefined;
374
- if (pkg) {
375
- movePackageIntoFailed(stagingDir, pkg, path.join(stagingDir, `${base}.meta.json`));
376
- rescued.push(pkg);
377
- }
378
- else {
379
- fs.rmSync(path.join(stagingDir, entry), { force: true });
380
- removed++;
381
- removedNames.push(entry);
382
- }
383
- handled.add(entry);
384
- handled.add(`${base}.json`);
385
- handled.add(`${base}.meta.json`);
378
+ if (!entry.endsWith(".tar.gz"))
386
379
  continue;
380
+ const base = entry.slice(0, -".tar.gz".length);
381
+ const pkg = rescueEnabled ? loadIntactPackage(stagingDir, base, names) : undefined;
382
+ if (pkg) {
383
+ movePackageIntoFailed(stagingDir, pkg, path.join(stagingDir, `${base}.meta.json`));
384
+ rescued.push(pkg);
385
+ }
386
+ else {
387
+ fs.rmSync(path.join(stagingDir, entry), { force: true });
388
+ removed++;
389
+ removedNames.push(entry);
387
390
  }
391
+ handled.add(entry);
392
+ handled.add(`${base}.json`);
393
+ handled.add(`${base}.meta.json`);
394
+ }
395
+ // 第二遍:清理剩余孤儿(无对应 tar.gz 的快照副本/meta、组装残留目录)
396
+ for (const entry of entries) {
397
+ if (handled.has(entry))
398
+ continue;
388
399
  if (entry.endsWith(".json") || entry.startsWith(".assembly-")) {
389
400
  fs.rmSync(path.join(stagingDir, entry), { recursive: true, force: true });
390
401
  removed++;
@@ -560,6 +571,8 @@ function removeQuietly(filePath) {
560
571
  // ─── 孤儿快照补传(§13 失败补偿链路) ─────────────────────────────────────────
561
572
  /** 已传终态标记目录名(metrics/.sent/<sessionId>) */
562
573
  const SENT_MARKER_DIR = ".sent";
574
+ /** 已传标记时效缓冲:快照 mtime 超出标记时间该值仍视为"标记后更新过"需补传 */
575
+ const SENT_NEWER_GRACE_MS = 60_000;
563
576
  /**
564
577
  * 记录会话终态包已成功上云。
565
578
  * 快照原件(metrics/<sessionId>.json)会永久保留为本地记录,
@@ -578,9 +591,23 @@ export function markSessionUploaded(metricsDir, sessionId) {
578
591
  export function isSessionUploaded(metricsDir, sessionId) {
579
592
  return fs.existsSync(path.join(metricsDir, SENT_MARKER_DIR, sessionId));
580
593
  }
594
+ /** 读取已传标记时间戳(内容为上传完成时刻的毫秒值);无标记/内容非法返回 null */
595
+ function sentTimestamp(metricsDir, sessionId) {
596
+ try {
597
+ const content = fs.readFileSync(path.join(metricsDir, SENT_MARKER_DIR, sessionId), "utf-8").trim();
598
+ const time = Number(content);
599
+ return Number.isFinite(time) && time > 0 ? time : null;
600
+ }
601
+ catch {
602
+ return null;
603
+ }
604
+ }
581
605
  /**
582
606
  * 扫描目录选出孤儿快照候选:mtime 静默 [idleMs, maxAgeMs] 且不在活跃集的快照。
583
- * 上传成功后快照即被删除,"文件仍在"天然等价于"终态未传出"。
607
+ * 快照原件永久保留为本地记录,"已传标记"是防重复上传的依据。
608
+ * 时效判断:标记只证明"标记时刻之前的快照已上云"——会话在中途终态触发
609
+ * (task 完成/session.deleted)上传后仍可能继续运行并更新快照,因此快照
610
+ * mtime 晚于标记时间(超出 60s 缓冲)仍视为孤儿,补传最终态。
584
611
  *
585
612
  * @param metricsDir 快照目录
586
613
  * @param idleMs 静默下限(毫秒)
@@ -602,11 +629,14 @@ export async function selectOrphanSnapshots(metricsDir, idleMs, maxAgeMs, isActi
602
629
  if (!entry.endsWith(".json"))
603
630
  continue;
604
631
  const sessionId = entry.slice(0, -".json".length);
605
- if (isSessionUploaded(metricsDir, sessionId))
606
- continue;
607
632
  const filePath = path.join(metricsDir, entry);
608
633
  try {
609
634
  const stats = await fs.promises.stat(filePath);
635
+ // 已传标记时效判断:标记只证明"标记时刻之前的快照已上云",快照 mtime
636
+ // 晚于标记时间(超出 60s 缓冲)说明会话其后仍更新过,最终态未上云,仍补传
637
+ const sentAt = sentTimestamp(metricsDir, sessionId);
638
+ if (sentAt !== null && stats.mtimeMs <= sentAt + SENT_NEWER_GRACE_MS)
639
+ continue;
610
640
  const age = now - stats.mtimeMs;
611
641
  if (age < idleMs || age > maxAgeMs)
612
642
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-metrics-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "opencode plugin: auto-records every session's metrics (tokens/rounds/steps/sub-agents/hvigor builds) to snapshot JSON. Zero DB, zero config. Pair with session-viewer for visualization.",
5
5
  "type": "module",
6
6
  "license": "MIT",