@openclaw/plugin-inspector 0.3.4 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ _No unreleased changes._
6
+
7
+ ## 0.3.5 - 2026-04-29
8
+
9
+ ### Fixed
10
+
11
+ - Add immediate/faster subprocess RSS and CPU sampling plus explicit sample counts so short import-loop reports do not silently publish fake zero-memory metrics.
12
+ - Classify `createChatChannelPlugin` as channel factory metadata in synthetic probe plans so channel-core plugins do not fail as unknown registrars.
13
+ - Treat `createChatChannelPlugin` and `defineChannelPluginEntry` as channel registration equivalents when validating fixture expectations.
14
+ - Label runtime profile wall-time summaries as command-median p95 and render missing sampled metrics as `n/a`.
15
+
5
16
  ## 0.3.4 - 2026-04-29
6
17
 
7
18
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/plugin-inspector",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "private": false,
5
5
  "description": "Offline compatibility inspector for OpenClaw plugins.",
6
6
  "type": "module",
package/src/ci-summary.js CHANGED
@@ -58,6 +58,8 @@ export async function buildCiSummary(options = {}) {
58
58
  importLoopP95Ms: reports.importLoop?.summary?.p95WallMs ?? 0,
59
59
  importLoopMaxRssMb: reports.importLoop?.summary?.maxPeakRssMb ?? 0,
60
60
  importLoopMaxCpuMs: reports.importLoop?.summary?.maxCpuMsEstimate ?? 0,
61
+ importLoopRssSampleCount: metricSampleCount(reports.importLoop, "rss", "maxPeakRssMb"),
62
+ importLoopCpuSampleCount: metricSampleCount(reports.importLoop, "cpu", "maxCpuMsEstimate"),
61
63
  },
62
64
  topIssues: topIssues(reports.compatibility),
63
65
  refRegressions: (reports.refDiff?.regressions ?? []).slice(0, 20),
@@ -148,7 +150,7 @@ export function renderCiSummaryMarkdown(summary) {
148
150
  ["Jiti loader candidates", summary.summary.loaderJitiCandidates],
149
151
  [
150
152
  "Import loop",
151
- `p50 ${summary.summary.importLoopP50Ms} ms / p95 ${summary.summary.importLoopP95Ms} ms / max RSS ${summary.summary.importLoopMaxRssMb} MB / CPU ${summary.summary.importLoopMaxCpuMs} ms`,
153
+ `p50 ${summary.summary.importLoopP50Ms} ms / p95 ${summary.summary.importLoopP95Ms} ms / max RSS ${formatSampledMetric(summary.summary.importLoopMaxRssMb, summary.summary.importLoopRssSampleCount)} / CPU ${formatSampledMetric(summary.summary.importLoopMaxCpuMs, summary.summary.importLoopCpuSampleCount, "ms")}`,
152
154
  ],
153
155
  ],
154
156
  ["Metric", "Value"],
@@ -221,3 +223,35 @@ function topIssues(report) {
221
223
  function markdownTable(rows, headers) {
222
224
  return renderPaddedMarkdownTable(rows, headers, { nullValue: "-" });
223
225
  }
226
+
227
+ function metricSampleCount(report, kind, maxMetric) {
228
+ const summaryKey = kind === "rss" ? "rssSampleCount" : "cpuSampleCount";
229
+ const summaryCount = report?.summary?.[summaryKey];
230
+ if (Number.isFinite(summaryCount)) {
231
+ return summaryCount;
232
+ }
233
+ const sampleCount = inferSampleCount(report?.samples, kind);
234
+ if (sampleCount > 0) {
235
+ return sampleCount;
236
+ }
237
+ return (report?.summary?.[maxMetric] ?? 0) > 0 ? 1 : 0;
238
+ }
239
+
240
+ function inferSampleCount(samples = [], kind) {
241
+ if (!Array.isArray(samples)) {
242
+ return 0;
243
+ }
244
+ return samples.reduce((sum, sample) => {
245
+ if (kind === "rss") {
246
+ return sum + (sample.rssSampleCount ?? (sample.peakRssMb > 0 ? 1 : 0));
247
+ }
248
+ return sum + (sample.cpuSampleCount ?? 0);
249
+ }, 0);
250
+ }
251
+
252
+ function formatSampledMetric(value, count, unit = "MB") {
253
+ if ((count ?? 0) <= 0) {
254
+ return "n/a";
255
+ }
256
+ return `${value} ${unit}`;
257
+ }
@@ -7,6 +7,7 @@ import {
7
7
  } from "./synthetic-probes.js";
8
8
 
9
9
  export const defaultRegistrationAssertions = {
10
+ createChatChannelPlugin: ["channel plugin id is stable", "channel factory metadata is captured"],
10
11
  defineChannelPluginEntry: ["channel id is stable", "setup/config schema can be read", "message envelope metadata is preserved"],
11
12
  definePluginEntry: ["entrypoint register function is callable", "entrypoint metadata is preserved"],
12
13
  registerChannel: ["channel id is stable", "inbound/outbound envelope shape is captured", "sender metadata is preserved"],
@@ -30,6 +30,9 @@ export async function buildImportLoopProfile(options = {}) {
30
30
  }
31
31
 
32
32
  const wallMs = samples.map((sample) => sample.wallMs).sort((left, right) => left - right);
33
+ const rssSampleCount = samples.reduce((sum, sample) => sum + (sample.rssSampleCount ?? (sample.peakRssMb > 0 ? 1 : 0)), 0);
34
+ const cpuSampleCount = samples.reduce((sum, sample) => sum + (sample.cpuSampleCount ?? 0), 0);
35
+ const statSampleCount = samples.reduce((sum, sample) => sum + (sample.statSampleCount ?? 0), 0);
33
36
  return {
34
37
  generatedAt: options.generatedAt ?? defaultImportLoopProfileOptions.generatedAt,
35
38
  mode: options.mode ?? "subprocess-cold-import-loop",
@@ -40,6 +43,9 @@ export async function buildImportLoopProfile(options = {}) {
40
43
  p95WallMs: percentile(wallMs, 0.95),
41
44
  maxPeakRssMb: Math.max(0, ...samples.map((sample) => sample.peakRssMb)),
42
45
  maxCpuMsEstimate: Math.max(0, ...samples.map((sample) => sample.cpuMsEstimate)),
46
+ statSampleCount,
47
+ rssSampleCount,
48
+ cpuSampleCount,
43
49
  capturedCount: samples.reduce((sum, sample) => sum + sample.capturedCount, 0),
44
50
  failCount: samples.filter((sample) => sample.exitCode !== 0 || sample.status !== "captured").length,
45
51
  },
@@ -85,7 +91,7 @@ export function renderImportLoopProfileMarkdown(report, options = {}) {
85
91
  "",
86
92
  "## Summary",
87
93
  "",
88
- markdownTable(Object.entries(report.summary).map(([key, value]) => [key, value]), ["Metric", "Value"]),
94
+ markdownTable(summaryRows(report), ["Metric", "Value"]),
89
95
  "",
90
96
  "## Samples",
91
97
  "",
@@ -95,11 +101,12 @@ export function renderImportLoopProfileMarkdown(report, options = {}) {
95
101
  sample.status,
96
102
  sample.capturedCount,
97
103
  `${sample.wallMs} ms`,
98
- `${sample.peakRssMb} MB`,
99
- `${sample.cpuMsEstimate} ms`,
104
+ formatSampledMetric(sample.peakRssMb, sample.rssSampleCount),
105
+ formatSampledMetric(sample.cpuMsEstimate, sample.cpuSampleCount, "ms"),
106
+ `${sample.rssSampleCount ?? 0}/${sample.cpuSampleCount ?? 0}`,
100
107
  sample.exitCode,
101
108
  ]),
102
- ["Run", "Status", "Captured", "Wall", "Peak RSS", "CPU Estimate", "Exit"],
109
+ ["Run", "Status", "Captured", "Wall", "Peak RSS", "CPU Estimate", "RSS/CPU samples", "Exit"],
103
110
  ),
104
111
  ].join("\n");
105
112
  }
@@ -130,10 +137,35 @@ async function runCaptureSample(options) {
130
137
  peakRssMb: profile.peakRssMb,
131
138
  peakCpuPercent: profile.peakCpuPercent,
132
139
  cpuMsEstimate: profile.cpuMsEstimate,
140
+ statSampleCount: profile.statSampleCount,
141
+ rssSampleCount: profile.rssSampleCount,
142
+ cpuSampleCount: profile.cpuSampleCount,
133
143
  stderrPreview: profile.stderrPreview,
134
144
  };
135
145
  }
136
146
 
147
+ function summaryRows(report) {
148
+ return [
149
+ ["runs", report.summary.runs],
150
+ ["p50WallMs", report.summary.p50WallMs],
151
+ ["p95WallMs", report.summary.p95WallMs],
152
+ ["maxPeakRssMb", formatSampledMetric(report.summary.maxPeakRssMb, report.summary.rssSampleCount)],
153
+ ["maxCpuMsEstimate", formatSampledMetric(report.summary.maxCpuMsEstimate, report.summary.cpuSampleCount, "ms")],
154
+ ["statSampleCount", report.summary.statSampleCount ?? 0],
155
+ ["rssSampleCount", report.summary.rssSampleCount ?? 0],
156
+ ["cpuSampleCount", report.summary.cpuSampleCount ?? 0],
157
+ ["capturedCount", report.summary.capturedCount],
158
+ ["failCount", report.summary.failCount],
159
+ ];
160
+ }
161
+
162
+ function formatSampledMetric(value, count, unit = "MB") {
163
+ if ((count ?? 0) <= 0) {
164
+ return "n/a";
165
+ }
166
+ return `${value} ${unit}`;
167
+ }
168
+
137
169
  function buildCaptureCommand(options) {
138
170
  if (typeof options.captureCommand === "function") {
139
171
  return options.captureCommand({
package/src/inspector.js CHANGED
@@ -11,6 +11,9 @@ import { readOpenClawTargetSurface } from "./openclaw-target.js";
11
11
  import { buildCompatibilityReport, buildReport } from "./report.js";
12
12
 
13
13
  const execFileAsync = promisify(execFile);
14
+ const registrationEquivalents = new Map([
15
+ ["registerChannel", new Set(["createChatChannelPlugin", "defineChannelPluginEntry", "registerChannel"])],
16
+ ]);
14
17
 
15
18
  export async function inspectFixtureSet(config, options = {}) {
16
19
  const { inspections, failures } = await inspectConfiguredFixtures(config, options);
@@ -58,7 +61,7 @@ async function inspectConfiguredFixtures(config, options = {}) {
58
61
  ["manifestContracts", inspection.manifestContracts],
59
62
  ]) {
60
63
  const expected = fixture.expect?.[key] ?? [];
61
- const missing = expected.filter((value) => !observed.includes(value));
64
+ const missing = expected.filter((value) => !satisfiesExpectedSeam(key, value, observed));
62
65
  if (missing.length > 0) {
63
66
  failures.push(`${fixture.id}: missing ${key}: ${missing.join(", ")}`);
64
67
  }
@@ -68,6 +71,17 @@ async function inspectConfiguredFixtures(config, options = {}) {
68
71
  return { inspections, failures };
69
72
  }
70
73
 
74
+ function satisfiesExpectedSeam(key, expected, observed) {
75
+ if (observed.includes(expected)) {
76
+ return true;
77
+ }
78
+ if (key !== "registrations") {
79
+ return false;
80
+ }
81
+ const equivalents = registrationEquivalents.get(expected);
82
+ return Boolean(equivalents && observed.some((value) => equivalents.has(value)));
83
+ }
84
+
71
85
  export async function inspectPlugin(fixture, options = {}) {
72
86
  const config = options.config ?? { rootDir: options.rootDir ?? process.cwd() };
73
87
  const checkoutPath = fixtureCheckoutPath(config, fixture);
@@ -7,8 +7,12 @@ export async function runProfiledProcess(options) {
7
7
  let firstRssKb = 0;
8
8
  let peakRssKb = 0;
9
9
  let peakCpuPercent = 0;
10
+ let statSampleCount = 0;
11
+ let rssSampleCount = 0;
12
+ let cpuSampleCount = 0;
10
13
  const cpuSamples = [];
11
14
  let pollInFlight = false;
15
+ const pendingStats = new Set();
12
16
 
13
17
  const child = spawn(options.command, options.args ?? [], {
14
18
  cwd: options.cwd,
@@ -21,27 +25,43 @@ export async function runProfiledProcess(options) {
21
25
  child.stderr?.on("data", (chunk) => stderr.push(chunk));
22
26
 
23
27
  const recordStats = (stats) => {
24
- if (stats.rssKb > 0 && firstRssKb === 0) {
28
+ if (stats.rssAvailable || stats.cpuAvailable) {
29
+ statSampleCount += 1;
30
+ }
31
+ if (stats.rssAvailable) {
32
+ rssSampleCount += 1;
33
+ }
34
+ if (stats.cpuAvailable) {
35
+ cpuSampleCount += 1;
36
+ }
37
+ if (stats.rssAvailable && stats.rssKb > 0 && firstRssKb === 0) {
25
38
  firstRssKb = stats.rssKb;
26
39
  }
27
- peakRssKb = Math.max(peakRssKb, stats.rssKb);
28
- peakCpuPercent = Math.max(peakCpuPercent, stats.cpuPercent);
29
- if (stats.cpuPercent > 0) {
40
+ if (stats.rssAvailable) {
41
+ peakRssKb = Math.max(peakRssKb, stats.rssKb);
42
+ }
43
+ if (stats.cpuAvailable) {
44
+ peakCpuPercent = Math.max(peakCpuPercent, stats.cpuPercent);
30
45
  cpuSamples.push(stats.cpuPercent);
31
46
  }
32
47
  };
33
48
 
34
- const poll = setInterval(() => {
49
+ const sampleStats = () => {
35
50
  if (pollInFlight) {
36
51
  return;
37
52
  }
38
53
  pollInFlight = true;
39
- readProcessStats(child.pid)
54
+ const pending = readProcessStats(child.pid)
40
55
  .then(recordStats)
41
56
  .finally(() => {
42
57
  pollInFlight = false;
58
+ pendingStats.delete(pending);
43
59
  });
44
- }, options.pollMs ?? 100);
60
+ pendingStats.add(pending);
61
+ };
62
+
63
+ sampleStats();
64
+ const poll = setInterval(sampleStats, options.pollMs ?? 25);
45
65
 
46
66
  const exitCode = await new Promise((resolve, reject) => {
47
67
  child.on("error", (error) => {
@@ -51,16 +71,10 @@ export async function runProfiledProcess(options) {
51
71
  child.on("exit", (code) => resolve(code ?? 1));
52
72
  });
53
73
  clearInterval(poll);
74
+ await Promise.allSettled([...pendingStats]);
54
75
 
55
76
  const finalStats = await readProcessStats(child.pid);
56
- if (finalStats.rssKb > 0 && firstRssKb === 0) {
57
- firstRssKb = finalStats.rssKb;
58
- }
59
- peakRssKb = Math.max(peakRssKb, finalStats.rssKb);
60
- peakCpuPercent = Math.max(peakCpuPercent, finalStats.cpuPercent);
61
- if (finalStats.cpuPercent > 0) {
62
- cpuSamples.push(finalStats.cpuPercent);
63
- }
77
+ recordStats(finalStats);
64
78
 
65
79
  const wallMs = Math.round(performance.now() - start);
66
80
  const averageCpuPercent =
@@ -79,6 +93,9 @@ export async function runProfiledProcess(options) {
79
93
  peakCpuPercent: Math.round(peakCpuPercent * 10) / 10,
80
94
  cpuMsEstimate: Math.round((wallMs * cpuPercentForEstimate) / 100),
81
95
  harnessHeapDeltaMb: Math.round((heapUsedMb() - heapStartMb) * 10) / 10,
96
+ statSampleCount,
97
+ rssSampleCount,
98
+ cpuSampleCount,
82
99
  exitCode,
83
100
  stdoutPreview: previewLines(stdout),
84
101
  stderrPreview: previewLines(stderr),
@@ -87,7 +104,7 @@ export async function runProfiledProcess(options) {
87
104
 
88
105
  async function readProcessStats(pid) {
89
106
  if (!pid || process.platform === "win32") {
90
- return { rssKb: 0, cpuPercent: 0 };
107
+ return { rssAvailable: false, rssKb: 0, cpuAvailable: false, cpuPercent: 0 };
91
108
  }
92
109
  return new Promise((resolve) => {
93
110
  const ps = spawn("ps", ["-o", "rss=", "-o", "%cpu=", "-p", String(pid)], {
@@ -95,14 +112,18 @@ async function readProcessStats(pid) {
95
112
  });
96
113
  const chunks = [];
97
114
  ps.stdout.on("data", (chunk) => chunks.push(chunk));
98
- ps.on("error", () => resolve({ rssKb: 0, cpuPercent: 0 }));
115
+ ps.on("error", () => resolve({ rssAvailable: false, rssKb: 0, cpuAvailable: false, cpuPercent: 0 }));
99
116
  ps.on("exit", () => {
100
117
  const [rssRaw, cpuRaw] = Buffer.concat(chunks).toString("utf8").trim().split(/\s+/);
101
118
  const rssKb = Number.parseInt(rssRaw, 10);
102
119
  const cpuPercent = Number.parseFloat(cpuRaw);
120
+ const rssAvailable = Number.isFinite(rssKb);
121
+ const cpuAvailable = Number.isFinite(cpuPercent);
103
122
  resolve({
104
- rssKb: Number.isFinite(rssKb) ? rssKb : 0,
105
- cpuPercent: Number.isFinite(cpuPercent) ? cpuPercent : 0,
123
+ rssAvailable,
124
+ rssKb: rssAvailable ? rssKb : 0,
125
+ cpuAvailable,
126
+ cpuPercent: cpuAvailable ? cpuPercent : 0,
106
127
  });
107
128
  });
108
129
  });
@@ -46,8 +46,8 @@ export async function buildRuntimeProfile(options = {}) {
46
46
  os: process.platform,
47
47
  arch: process.arch,
48
48
  node: process.version,
49
- rssSampler: process.platform === "win32" ? "unavailable" : "ps",
50
- cpuSampler: process.platform === "win32" ? "unavailable" : "ps-percent",
49
+ rssSampler: process.platform === "win32" ? "unavailable" : "ps-immediate-25ms",
50
+ cpuSampler: process.platform === "win32" ? "unavailable" : "ps-percent-immediate-25ms",
51
51
  },
52
52
  summary: summarizeProfile(commands),
53
53
  groups: summarizeCommandGroups(commands),
@@ -65,8 +65,8 @@ export function validateRuntimeProfile(profile) {
65
65
  errors.push(`${command.id}: missing wall time`);
66
66
  }
67
67
  }
68
- if (profile.platform?.rssSampler !== "unavailable" && profile.commands.every((command) => command.peakRssMb.max <= 0)) {
69
- errors.push("all commands are missing peak RSS");
68
+ if (profile.platform?.rssSampler !== "unavailable" && profile.commands.every((command) => !hasRssSample(command))) {
69
+ errors.push("all commands are missing peak RSS samples");
70
70
  }
71
71
  return errors;
72
72
  }
@@ -98,10 +98,17 @@ export function renderRuntimeProfileMarkdown(profile, options = {}) {
98
98
  [
99
99
  ["Commands", profile.summary.commandCount],
100
100
  ["P50 wall time", `${profile.summary.p50WallMs} ms`],
101
- ["P95 wall time", `${profile.summary.p95WallMs} ms`],
102
- ["Max peak RSS", `${profile.summary.maxPeakRssMb} MB`],
103
- ["Max RSS delta", `${profile.summary.maxRssDeltaMb} MB`],
104
- ["Max CPU estimate", `${profile.summary.maxCpuMsEstimate} ms`],
101
+ ["Command P95 wall time", `${profile.summary.p95WallMs} ms`],
102
+ ["Wall time basis", profile.summary.wallTimeBasis ?? "command-median-p95"],
103
+ ["Profile samples", profile.summary.sampleCount ?? sampleCount(profile.commands)],
104
+ ["RSS samples", profile.summary.rssSampleCount ?? rssSampleCount(profile.commands)],
105
+ ["CPU samples", profile.summary.cpuSampleCount ?? cpuSampleCount(profile.commands)],
106
+ ["Max peak RSS", formatSampledMetric(profile.summary.maxPeakRssMb, profile.summary.rssSampleCount ?? rssSampleCount(profile.commands))],
107
+ ["Max RSS delta", formatSampledMetric(profile.summary.maxRssDeltaMb, profile.summary.rssSampleCount ?? rssSampleCount(profile.commands))],
108
+ [
109
+ "Max CPU estimate",
110
+ formatSampledMetric(profile.summary.maxCpuMsEstimate, profile.summary.cpuSampleCount ?? cpuSampleCount(profile.commands), "ms"),
111
+ ],
105
112
  ["Max harness heap delta", `${profile.summary.maxHarnessHeapDeltaMb} MB`],
106
113
  ],
107
114
  ["Metric", "Value"],
@@ -129,13 +136,14 @@ export function renderRuntimeProfileMarkdown(profile, options = {}) {
129
136
  command.label,
130
137
  `${command.wallMs.median} ms`,
131
138
  `${command.wallMs.max} ms`,
132
- `${command.peakRssMb.max} MB`,
133
- `${command.rssDeltaMb.max} MB`,
134
- `${command.cpuMsEstimate.max} ms`,
139
+ formatSampledMetric(command.peakRssMb.max, command.rssSampleCount),
140
+ formatSampledMetric(command.rssDeltaMb.max, command.rssSampleCount),
141
+ formatSampledMetric(command.cpuMsEstimate.max, command.cpuSampleCount, "ms"),
135
142
  `${command.harnessHeapDeltaMb.max} MB`,
143
+ `${command.rssSampleCount ?? 0}/${command.cpuSampleCount ?? 0}`,
136
144
  command.exitCodes.join(", "),
137
145
  ]),
138
- ["ID", "Label", "Median wall", "Max wall", "Max peak RSS", "Max RSS delta", "CPU estimate", "Heap delta", "Exit codes"],
146
+ ["ID", "Label", "Median wall", "Max wall", "Max peak RSS", "Max RSS delta", "CPU estimate", "Heap delta", "RSS/CPU samples", "Exit codes"],
139
147
  ),
140
148
  "",
141
149
  "## Category Rollups",
@@ -146,11 +154,12 @@ export function renderRuntimeProfileMarkdown(profile, options = {}) {
146
154
  group.commandCount,
147
155
  `${group.p50WallMs} ms`,
148
156
  `${group.p95WallMs} ms`,
149
- `${group.maxPeakRssMb} MB`,
150
- `${group.maxCpuMsEstimate} ms`,
157
+ formatSampledMetric(group.maxPeakRssMb, group.rssSampleCount),
158
+ formatSampledMetric(group.maxCpuMsEstimate, group.cpuSampleCount, "ms"),
159
+ `${group.rssSampleCount ?? 0}/${group.cpuSampleCount ?? 0}`,
151
160
  group.commands.join(", "),
152
161
  ]),
153
- ["Category", "Commands", "P50 wall", "P95 wall", "Max peak RSS", "CPU estimate", "Command IDs"],
162
+ ["Category", "Commands", "P50 wall", "P95 wall", "Max peak RSS", "CPU estimate", "RSS/CPU samples", "Command IDs"],
154
163
  ),
155
164
  ].join("\n");
156
165
  }
@@ -189,8 +198,15 @@ function summarizeProfile(commands) {
189
198
  const maxRssDeltaMb = Math.max(0, ...commands.map((command) => command.rssDeltaMb.max));
190
199
  const maxCpuMsEstimate = Math.max(0, ...commands.map((command) => command.cpuMsEstimate.max));
191
200
  const maxHarnessHeapDeltaMb = Math.max(0, ...commands.map((command) => command.harnessHeapDeltaMb.max));
201
+ const totalSampleCount = sampleCount(commands);
202
+ const totalRssSampleCount = rssSampleCount(commands);
203
+ const totalCpuSampleCount = cpuSampleCount(commands);
192
204
  return {
193
205
  commandCount: commands.length,
206
+ sampleCount: totalSampleCount,
207
+ rssSampleCount: totalRssSampleCount,
208
+ cpuSampleCount: totalCpuSampleCount,
209
+ wallTimeBasis: "command-median-p95",
194
210
  p50WallMs: percentile(wallTimes, 0.5),
195
211
  p95WallMs: percentile(wallTimes, 0.95),
196
212
  maxPeakRssMb,
@@ -206,6 +222,12 @@ function summarizeCommand(command, samples) {
206
222
  const rssDeltaMb = samples.map((sample) => sample.rssDeltaMb).sort((left, right) => left - right);
207
223
  const peakCpuPercent = samples.map((sample) => sample.peakCpuPercent).sort((left, right) => left - right);
208
224
  const cpuMsEstimate = samples.map((sample) => sample.cpuMsEstimate).sort((left, right) => left - right);
225
+ const statSampleCount = samples.reduce((sum, sample) => sum + (sample.statSampleCount ?? 0), 0);
226
+ const rssSampleTotal = samples.reduce(
227
+ (sum, sample) => sum + (sample.rssSampleCount ?? (sample.peakRssMb > 0 ? 1 : 0)),
228
+ 0,
229
+ );
230
+ const cpuSampleTotal = samples.reduce((sum, sample) => sum + (sample.cpuSampleCount ?? 0), 0);
209
231
  const harnessHeapDeltaMb = samples
210
232
  .map((sample) => sample.harnessHeapDeltaMb)
211
233
  .sort((left, right) => left - right);
@@ -222,6 +244,9 @@ function summarizeCommand(command, samples) {
222
244
  peakCpuPercent: summarizeNumbers(peakCpuPercent),
223
245
  cpuMsEstimate: summarizeNumbers(cpuMsEstimate),
224
246
  harnessHeapDeltaMb: summarizeNumbers(harnessHeapDeltaMb),
247
+ statSampleCount,
248
+ rssSampleCount: rssSampleTotal,
249
+ cpuSampleCount: cpuSampleTotal,
225
250
  exitCodes: [...new Set(samples.map((sample) => sample.exitCode))].sort(),
226
251
  };
227
252
  }
@@ -244,6 +269,8 @@ function summarizeCommandGroups(commands) {
244
269
  const cpuMs = categoryCommands
245
270
  .flatMap((command) => command.samples.map((sample) => sample.cpuMsEstimate))
246
271
  .sort((left, right) => left - right);
272
+ const groupRssSampleCount = rssSampleCount(categoryCommands);
273
+ const groupCpuSampleCount = cpuSampleCount(categoryCommands);
247
274
  return {
248
275
  category,
249
276
  commandCount: categoryCommands.length,
@@ -251,11 +278,36 @@ function summarizeCommandGroups(commands) {
251
278
  p95WallMs: percentile(wallTimes, 0.95),
252
279
  maxPeakRssMb: peakRss.at(-1) ?? 0,
253
280
  maxCpuMsEstimate: cpuMs.at(-1) ?? 0,
281
+ rssSampleCount: groupRssSampleCount,
282
+ cpuSampleCount: groupCpuSampleCount,
254
283
  commands: categoryCommands.map((command) => command.id),
255
284
  };
256
285
  });
257
286
  }
258
287
 
288
+ function hasRssSample(command) {
289
+ return (command.rssSampleCount ?? (command.peakRssMb?.max > 0 ? 1 : 0)) > 0;
290
+ }
291
+
292
+ function sampleCount(commands) {
293
+ return commands.reduce((sum, command) => sum + (command.samples?.length ?? 0), 0);
294
+ }
295
+
296
+ function rssSampleCount(commands) {
297
+ return commands.reduce((sum, command) => sum + (command.rssSampleCount ?? (command.peakRssMb?.max > 0 ? 1 : 0)), 0);
298
+ }
299
+
300
+ function cpuSampleCount(commands) {
301
+ return commands.reduce((sum, command) => sum + (command.cpuSampleCount ?? 0), 0);
302
+ }
303
+
304
+ function formatSampledMetric(value, count, unit = "MB") {
305
+ if ((count ?? 0) <= 0) {
306
+ return "n/a";
307
+ }
308
+ return `${value} ${unit}`;
309
+ }
310
+
259
311
  function summarizeNumbers(values) {
260
312
  return {
261
313
  min: values[0],
@@ -1,6 +1,11 @@
1
1
  import { renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
2
2
 
3
3
  export const syntheticRegistrationExecutionProfiles = {
4
+ createChatChannelPlugin: {
5
+ mode: "metadata-only",
6
+ callableProperties: [],
7
+ reason: "channel plugin factory metadata is captured before channel runtime execution",
8
+ },
4
9
  defineChannelPluginEntry: {
5
10
  mode: "metadata-only",
6
11
  callableProperties: [],
@@ -326,6 +331,7 @@ export const defaultSyntheticHookContexts = {
326
331
  };
327
332
 
328
333
  export const defaultSyntheticRegistrationArguments = {
334
+ createChatChannelPlugin: [{ base: { id: "fixture-channel" }, outbound: { sendText: "function" } }],
329
335
  defineChannelPluginEntry: [{ id: "fixture-channel", setup: "function", receive: "function" }],
330
336
  definePluginEntry: [{ id: "fixture-plugin", register: "function" }],
331
337
  registerChannel: [{ id: "fixture-channel", send: "function", receive: "function" }],