gm-plugkit 2.0.1630 → 2.0.1632

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.
@@ -24,13 +24,17 @@ url=<url>\n<expression>
24
24
  timeout=<ms>\n<expression>
25
25
  capture\n<expression>
26
26
  profile\n<expression>
27
+ profile interval=<us> topN=<n>\n<expression>
28
+ trace\n<expression>
27
29
  ```
28
30
 
29
31
  **Open on the page you want to test, not a blank one.** A bare `https://...` URL body navigates the session straight to that page and returns `{url, title}` -- the simplest "show me this page." `url=<url>\n<expression>` navigates first, then runs your expression on the loaded page, so the global/DOM you assert is already there in one dispatch instead of a blank surface you must `page.goto` yourself. `url=` composes with `timeout=` and `capture` -- stack the prefix lines in order `timeout=`, then `url=`, then `capture`, the expression last; the prepended `page.goto` rides inside the capture so its navigation console/network is captured too. A bare expression with no `url=`/bare-URL prefix runs against whatever the session is already on -- a never-navigated session is on `about:blank`, so the expression evaluates an empty page and the envelope comes back with `landed_on_blank: true` and a `hint` telling you to add `url=`; navigate first and the surprise never happens. `session new` returns the id you carry. (`session close` and `session kill` are aliases.) Default per-eval timeout 120000ms; operations that legitimately exceed it prefix `timeout=<ms>\n` (wrapper clamps to 120000ms). The response carries `timeout_ms_used`; `browser.runner-timeout` fires at the cap -- read `stderr`, narrow or raise, never retry blind at the same budget.
30
32
 
31
33
  **`capture\n<expression>` is the zero-boilerplate debug path -- prefer it.** Prefix your script with `capture` (or `profile`) on its own line and the wrapper auto-attaches `page.on('console'|'pageerror'|'requestfinished')` before your code runs, runs your script in an async wrapper (your top-level `await`/`return` work unchanged), and returns `{result: <your return>, debug: {console, pageErrors, network, performance}}` -- page console logs, uncaught errors, per-request network timing, and navigation performance, captured for free. Combine with timeout via `timeout=<ms>\ncapture\n<expr>`. Use the bare expression only when you do not want the capture overhead.
32
34
 
33
- **`profile\n<expression>` is the bottom-up CPU profiler -- worst-20 culprits by file location across init and code-execution.** Prefix your script with `profile` on its own line: the wrapper opens a CDP `Profiler` (`newCDPSession` + `Profiler.start` BEFORE the prepended `page.goto`, so navigation, script-parse, and init are sampled, not only steady-state), runs your script, `Profiler.stop`s, and aggregates the v8 CPU profile into `{result, profile: {timeframe: {start_us, end_us, total_us, sample_count}, culprits: [{location, function, self_us, self_pct, hits}]}, profile_error, debug: {...}}`. `culprits` is the bottom-up self-time ranking capped at the worst 20 `url:line` locations; `timeframe` is the capture window in microseconds. Composes with `url=`/`timeout=` in the same prefix order. Page scripts loaded from `.js` files carry real `file:line`; `page.evaluate` anonymous frames bucket to `(program)`/`(native)`. On a CDP failure `profile` is `null` with `profile_error` set and your `result` still returns. The identical `{timeframe, culprits}` shape comes back from `exec_js` with `opts.profile:true`, so the cli and browser bottom-up views read the same.
35
+ **`profile\n<expression>` is the bottom-up CPU profiler -- worst-20 culprits by file location across init and code-execution.** Prefix your script with `profile` on its own line: the wrapper opens a CDP `Profiler` (`newCDPSession` + `Profiler.start` BEFORE the prepended `page.goto`, so navigation, script-parse, and init are sampled, not only steady-state), runs your script, `Profiler.stop`s, and aggregates the v8 CPU profile into `{result, profile: {timeframe: {start_us, end_us, total_us, sample_count}, culprits: [{location, function, self_us, self_pct, hits}]}, profile_error, debug: {...}}`. `culprits` is the bottom-up self-time ranking capped at the worst 20 `url:line` locations; `timeframe` is the capture window in microseconds. Composes with `url=`/`timeout=` in the same prefix order. Page scripts loaded from `.js` files carry real `file:line`; `page.evaluate` anonymous frames bucket to `(program)`/`(native)`. On a CDP failure `profile` is `null` with `profile_error` set and your `result` still returns. The identical `{timeframe, culprits}` shape comes back from `exec_js` with `opts.profile:true`, so the cli and browser bottom-up views read the same. `profile` also returns `wall_vs_cpu: {wall_us, cpu_self_us, offcpu_us}` -- the CPU sampler measures only on-CPU JS, so `offcpu_us` is the time it cannot see. Tune the sampler with `interval=<us>` and the culprit count with `topN=<n>` stacked after the mode word (`profile interval=50 topN=40`), symmetric with `exec_js` `opts.sampleIntervalUs`/`opts.profileTopN`.
36
+
37
+ **`trace\n<expression>` catches GPU activity the CPU sampler is structurally blind to.** A V8 CPU profiler samples on-CPU JS call stacks only; GPU-process work -- compositor, raster, draw, WebGL/canvas -- never appears in it. `trace` opens a CDP `Tracing` session over wrapper-controlled categories (`gpu`, `viz`, `cc`, `blink`, `devtools.timeline`), runs your script, ends tracing, and returns `{result, trace: {wall_us, gpu_us, viz_us, cc_us, raster_us, event_count, complete, by_category}, trace_error, debug: {...}}`. `gpu_us`/`viz_us`/`cc_us` are wall-clock microseconds of GPU-process activity summed from the trace; `by_category` is the bounded top-15 category rollup (raw events never returned). When wall greatly exceeds CPU self-time, `trace` is how you attribute the gap to the GPU rather than guessing. `debug.performance` carries paint/frame metrics (`first_contentful_paint_ms`, `largest_contentful_paint_ms`, `cumulative_layout_shift`, `longtasks`, `fps`) for client-side render jank. `tracingComplete` is bounded by a timeout; on a CDP failure `trace_error` is set and `result`/`debug` still return.
34
38
 
35
39
  ## Envelope
36
40
 
@@ -38,7 +38,7 @@ First emit = closure of the transform; scaffold + IOU externalizes residual cost
38
38
 
39
39
  Data first -- get the structures and their invariants right and the code writes itself; convoluted control flow means the data model is wrong, so fix the model. Make invalid state unrepresentable -- pass parameters over hidden globals, encode the constraint in the type/shape so the bad combination cannot be constructed. Reason from physical constraints (latency, bandwidth, memory, coordination, the worst node) before designing within them. Keep the spine flat, each unit single-focus and understandable at its call site. Make misuse structurally impossible, not documented-against. Optimize the worst case, not the average; design every failure path explicitly (full -> degraded -> safe-fail -> explicit-error), never a silent catastrophic mode. Measure, do not assume -- profile before optimizing, implement both and compare on real input when in genuine dispute. When a change regresses something that worked, revert first and investigate second: restore green, then diagnose from a known-good base. Fail fast and loud over limping on bad state.
40
40
 
41
- **Process of elimination is the debugging paradigm on every surface, and manual labour against real services is how you witness.** This is thinking-in-code at its sharpest: each candidate cause is a hypothesis, and you test the hypothesis by running it, not by reasoning around it. Never guess-and-restart, a/b-test, or shotgun variants: enumerate the candidate causes as mutables, then eliminate each by a witness read against REAL input -- `exec_js` against the real service, `codesearch`/`Read` against the real source, the `browser` verb's `page.evaluate` against a `window.*` global on the live page. Each elimination reveals the next mutable; record it and keep going until one cause survives every other's refutation. Reading the live runtime once observes more than a hundred blind restarts. Profile on the real surface, not from intuition: wrap the suspect node and read the live numbers. In node, `exec_js` carries `duration_ms` for free, surfaces your own timing and `process.memoryUsage()` on stdout, and lands the thrown-error `stack` on stderr -- read both channels (numbers on stdout, stack on stderr). In the browser, a body prefixed `capture\n<script>` auto-returns `{result, debug:{console, pageErrors, network, performance}}` with zero boilerplate. When the slow node is not obvious, sample it bottom-up: `exec_js` with `opts.profile:true` and the browser `profile\n<script>` prefix both return `{result, profile:{timeframe:{start_us,end_us,total_us,sample_count}, culprits:[{location,function,self_us,self_pct,hits}]}}` -- the worst-20 `file:line` by self-time across init and code-execution, identical shape on both surfaces, so the culprit ranking points straight at the line to fix. Profile to LOCATE the slow/broken node, then eliminate hypotheses by live measurement. Verification is the same labour: run the real thing and witness the real output (the single mock-free `test.js`, the live page, the real service), never an automated unit/mock harness standing in for the real-services witness. Apparent tooling failure is part of this -- it is your mechanical self-recovery by elimination, never a question for the user.
41
+ **Process of elimination is the debugging paradigm on every surface, and manual labour against real services is how you witness.** This is thinking-in-code at its sharpest: each candidate cause is a hypothesis, and you test the hypothesis by running it, not by reasoning around it. Never guess-and-restart, a/b-test, or shotgun variants: enumerate the candidate causes as mutables, then eliminate each by a witness read against REAL input -- `exec_js` against the real service, `codesearch`/`Read` against the real source, the `browser` verb's `page.evaluate` against a `window.*` global on the live page. Each elimination reveals the next mutable; record it and keep going until one cause survives every other's refutation. Reading the live runtime once observes more than a hundred blind restarts. Profile on the real surface, not from intuition: wrap the suspect node and read the live numbers. In node, `exec_js` carries `duration_ms` for free, surfaces your own timing and `process.memoryUsage()` on stdout, and lands the thrown-error `stack` on stderr -- read both channels (numbers on stdout, stack on stderr). In the browser, a body prefixed `capture\n<script>` auto-returns `{result, debug:{console, pageErrors, network, performance}}` with zero boilerplate. When the slow node is not obvious, sample it bottom-up: `exec_js` with `opts.profile:true` and the browser `profile\n<script>` prefix both return `{result, profile:{timeframe:{start_us,end_us,total_us,sample_count}, culprits:[{location,function,self_us,self_pct,hits}]}}` -- the worst-N `file:line` by self-time across init and code-execution, identical shape on both surfaces, so the culprit ranking points straight at the line to fix. Both also return `mem` (rss/heap/delta) and `wall_vs_cpu:{wall_us, offcpu_us}` -- the sampler sees only on-CPU JS, so a large `offcpu_us` means the time is going to IO, async wait, or the GPU, not the JS you can see; tune with `opts.sampleIntervalUs`/`opts.profileTopN` (cli) or `interval=`/`topN=` (browser). The CPU sampler is structurally blind to GPU activity -- when wall greatly exceeds CPU self-time on a render/canvas/WebGL surface, the browser `trace\n<script>` prefix opens CDP Tracing and returns `trace:{wall_us, gpu_us, viz_us, cc_us, by_category}`, the wall-clock GPU-process time the profiler cannot show. Profile to LOCATE the slow/broken node, then eliminate hypotheses by live measurement. Verification is the same labour: run the real thing and witness the real output (the single mock-free `test.js`, the live page, the real service), never an automated unit/mock harness standing in for the real-services witness. Apparent tooling failure is part of this -- it is your mechanical self-recovery by elimination, never a question for the user.
42
42
 
43
43
  ## Memorize
44
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-plugkit",
3
- "version": "2.0.1630",
3
+ "version": "2.0.1632",
4
4
  "description": "Bootstrap and daemon-spawn tool for gm plugkit binary. Downloads the correct platform binary, verifies SHA256, and starts the spool watcher daemon. Includes plugkit-wasm-wrapper for WASM-based spool watching.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -549,8 +549,26 @@ const TMP_DIR = os.tmpdir();
549
549
  const LEGACY_BROWSER_PORTS_FILE = path.join(TMP_DIR, 'plugkit-browser-ports.json');
550
550
  const LEGACY_BROWSER_SESSIONS_FILE = path.join(TMP_DIR, 'plugkit-browser-sessions.json');
551
551
 
552
+ const __browserRootCache = new Map();
553
+ function browserRootDir(cwd) {
554
+ const start = path.resolve(cwd || process.cwd());
555
+ if (__browserRootCache.has(start)) return __browserRootCache.get(start);
556
+ let root = start;
557
+ try {
558
+ const r = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd: start, encoding: 'utf-8', windowsHide: true, timeout: 1500 });
559
+ if (r.status === 0 && r.stdout && r.stdout.trim()) {
560
+ let commonDir = r.stdout.trim();
561
+ if (!path.isAbsolute(commonDir)) commonDir = path.resolve(start, commonDir);
562
+ if (/(^|[\\/])\.git$/.test(commonDir)) root = path.dirname(commonDir);
563
+ }
564
+ } catch (_) {}
565
+ root = path.resolve(root);
566
+ __browserRootCache.set(start, root);
567
+ return root;
568
+ }
569
+
552
570
  function browserStateDir(cwd) {
553
- const dir = path.join(cwd || process.cwd(), '.gm', 'exec-spool');
571
+ const dir = path.join(browserRootDir(cwd), '.gm', 'exec-spool');
554
572
  try { fs.mkdirSync(dir, { recursive: true }); } catch (_) {}
555
573
  return dir;
556
574
  }
@@ -752,14 +770,15 @@ function sessionProfileSlug(claudeSessionId) {
752
770
  }
753
771
 
754
772
  function sessionProfileDir(cwd, claudeSessionId) {
755
- return path.join(cwd, '.gm', `browser-profile-${sessionProfileSlug(claudeSessionId)}`);
773
+ return path.join(browserRootDir(cwd), '.gm', `browser-profile-${sessionProfileSlug(claudeSessionId)}`);
756
774
  }
757
775
 
758
776
  function acquireProfileDir(cwd, claudeSessionId) {
759
- const gmDir = path.join(cwd, '.gm');
777
+ const root = browserRootDir(cwd);
778
+ const gmDir = path.join(root, '.gm');
760
779
  try { fs.mkdirSync(gmDir, { recursive: true }); } catch (_) {}
761
- ensureGitignored(cwd, '.gm/browser-profile/');
762
- ensureGitignored(cwd, '.gm/browser-profile-*/');
780
+ ensureGitignored(root, '.gm/browser-profile/');
781
+ ensureGitignored(root, '.gm/browser-profile-*/');
763
782
  const primary = sessionProfileDir(cwd, claudeSessionId);
764
783
  try { fs.mkdirSync(primary, { recursive: true }); } catch (_) {}
765
784
  if (!isProfileLocked(primary)) return primary;
@@ -770,7 +789,7 @@ function acquireProfileDir(cwd, claudeSessionId) {
770
789
 
771
790
  function cleanDeadProfileFragments(cwd) {
772
791
  try {
773
- const gmDir = path.join(cwd, '.gm');
792
+ const gmDir = path.join(browserRootDir(cwd), '.gm');
774
793
  if (!fs.existsSync(gmDir)) return { cleaned: 0 };
775
794
  let cleaned = 0;
776
795
  for (const name of fs.readdirSync(gmDir)) {
@@ -1202,6 +1221,41 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
1202
1221
  try { writeJsonFile(sessionsFile, sessions); } catch (_) {}
1203
1222
  }
1204
1223
  }
1224
+ const spawnLock = path.join(browserStateDir(cwd), `.browser-spawn-${sessionProfileSlug(claudeSessionId)}.lock`);
1225
+ let lockFd = null;
1226
+ const spawnDeadline = Date.now() + 35000;
1227
+ for (;;) {
1228
+ try { lockFd = fs.openSync(spawnLock, 'wx'); break; }
1229
+ catch (e) {
1230
+ if (e.code !== 'EEXIST') break;
1231
+ let stale = false;
1232
+ try {
1233
+ const owner = parseInt(String(fs.readFileSync(spawnLock, 'utf-8')).split('|')[0], 10);
1234
+ const ageOk = (Date.now() - fs.statSync(spawnLock).mtimeMs) < 40000;
1235
+ if (!ageOk || !(Number.isFinite(owner) && isProcessAliveSync(owner))) stale = true;
1236
+ } catch (_) { stale = true; }
1237
+ if (stale) { try { fs.unlinkSync(spawnLock); } catch (_) {} continue; }
1238
+ const winner = readJsonFile(portsFile, {})[claudeSessionId];
1239
+ if (winner && winner.pid && winner.wsEndpoint && isProcessAliveSync(winner.pid)
1240
+ && fetchJsonSync(`http://127.0.0.1:${winner.port}/json/version`, 1000)) {
1241
+ const a = runBrowserRunner(pw, ['session', 'new', '--direct', winner.wsEndpoint], 30000, cwd, claudeSessionId);
1242
+ const sid = a && a.status === 0 ? parseSessionId(a.stdout || '') : null;
1243
+ if (sid) { logEvent('plugkit', 'browser.attached', { pwSessionId: sid, reused: true, via: 'spawn-lock-wait' }); return sid; }
1244
+ }
1245
+ if (Date.now() > spawnDeadline) break;
1246
+ sleepSyncMs(300);
1247
+ }
1248
+ }
1249
+ try { if (lockFd !== null) { fs.writeSync(lockFd, `${process.pid}|${Date.now()}`); fs.closeSync(lockFd); } } catch (_) {}
1250
+ const releaseSpawnLock = () => { try { const o = parseInt(String(fs.readFileSync(spawnLock, 'utf-8')).split('|')[0], 10); if (o === process.pid) fs.unlinkSync(spawnLock); } catch (_) {} };
1251
+ try {
1252
+ const winner2 = readJsonFile(portsFile, {})[claudeSessionId];
1253
+ if (winner2 && winner2.pid && winner2.wsEndpoint && isProcessAliveSync(winner2.pid)
1254
+ && fetchJsonSync(`http://127.0.0.1:${winner2.port}/json/version`, 1000)) {
1255
+ const a = runBrowserRunner(pw, ['session', 'new', '--direct', winner2.wsEndpoint], 30000, cwd, claudeSessionId);
1256
+ const sid = a && a.status === 0 ? parseSessionId(a.stdout || '') : null;
1257
+ if (sid) { logEvent('plugkit', 'browser.attached', { pwSessionId: sid, reused: true, via: 'spawn-lock-recheck' }); return sid; }
1258
+ }
1205
1259
  cleanDeadProfileFragments(cwd);
1206
1260
  reapOrphanBrowserSessions(pw, cwd, claudeSessionId, 'pre-spawn');
1207
1261
  const profileDir = acquireProfileDir(cwd, claudeSessionId);
@@ -1243,6 +1297,7 @@ function getOrCreateBrowserSession(cwd, claudeSessionId, pw) {
1243
1297
  writeJsonFile(sessionsFile, sessions);
1244
1298
  logEvent('plugkit', 'browser.attached', { pwSessionId, pid: browserPid, port });
1245
1299
  return pwSessionId;
1300
+ } finally { releaseSpawnLock(); }
1246
1301
  }
1247
1302
 
1248
1303
  function parseSessionId(rawOut) {
@@ -1934,7 +1989,12 @@ function makeHostFunctions(instanceRef) {
1934
1989
  });
1935
1990
  }
1936
1991
  const timeoutMs = rawTimeout;
1937
- const wantProfile = opts.profile === true && (lang === 'nodejs' || lang === 'js' || lang === undefined);
1992
+ const isJsLang = lang === 'nodejs' || lang === 'js' || lang === undefined;
1993
+ const wantProfile = opts.profile === true && isJsLang;
1994
+ const profileSkipped = opts.profile === true && !isJsLang
1995
+ ? { reason: `profile requested but lang=${lang} is not js/nodejs; CPU profiling only supported on the node surface`, lang }
1996
+ : null;
1997
+ const profileTopN = Number.isFinite(opts.profileTopN) && opts.profileTopN > 0 ? Math.floor(opts.profileTopN) : 20;
1938
1998
  let profileUserFile = null;
1939
1999
  let cmd, args;
1940
2000
  if (lang === 'nodejs' || lang === 'js') {
@@ -1943,21 +2003,32 @@ function makeHostFunctions(instanceRef) {
1943
2003
  fs.writeFileSync(profileUserFile, `module.exports = (async () => {\n${code}\n});`, 'utf-8');
1944
2004
  const runnerCode = `${AGGREGATE_CPU_PROFILE_SRC}\n`
1945
2005
  + `const __inspector = require('inspector');\n`
2006
+ + `const { performance: __perf } = require('perf_hooks');\n`
1946
2007
  + `const __session = new __inspector.Session();\n`
1947
2008
  + `__session.connect();\n`
1948
2009
  + `const __post = (m, p) => new Promise((res, rej) => __session.post(m, p || {}, (e, r) => e ? rej(e) : res(r)));\n`
1949
2010
  + `(async () => {\n`
1950
- + ` let __profile = null, __profileError = null, __userResult = null, __userError = null;\n`
2011
+ + ` let __profile = null, __profileError = null, __userResult = null, __userError = null, __wallMs = 0;\n`
2012
+ + ` const __memBefore = process.memoryUsage();\n`
1951
2013
  + ` try {\n`
1952
2014
  + ` await __post('Profiler.enable');\n`
1953
2015
  + ` await __post('Profiler.setSamplingInterval', { interval: ${Number.isFinite(opts.sampleIntervalUs) && opts.sampleIntervalUs > 0 ? Math.floor(opts.sampleIntervalUs) : 100} });\n`
1954
2016
  + ` await __post('Profiler.start');\n`
2017
+ + ` const __w0 = __perf.now();\n`
1955
2018
  + ` try { __userResult = await require(${JSON.stringify(profileUserFile)})(); } catch (ue) { __userError = String(ue && ue.stack || ue); }\n`
2019
+ + ` __wallMs = Math.round((__perf.now() - __w0) * 1000) / 1000;\n`
1956
2020
  + ` const __r = await __post('Profiler.stop');\n`
1957
2021
  + ` __profile = __r && __r.profile || null;\n`
1958
2022
  + ` } catch (pe) { __profileError = String(pe && pe.message || pe); }\n`
1959
- + ` const __agg = __profile ? aggregateCpuProfile(__profile) : { timeframe: null, culprits: [] };\n`
1960
- + ` process.stdout.write('__GM_PROFILE__' + JSON.stringify({ result: __userResult, user_error: __userError, profile: __agg, profile_error: __profileError }));\n`
2023
+ + ` const __memAfter = process.memoryUsage();\n`
2024
+ + ` const __agg = __profile ? aggregateCpuProfile(__profile, ${profileTopN}) : { timeframe: null, culprits: [] };\n`
2025
+ + ` const __userFile = ${JSON.stringify('file:///' + profileUserFile.replace(/\\/g, '/'))};\n`
2026
+ + ` const __cpuTotalUs = __agg.timeframe ? __agg.timeframe.total_us : 0;\n`
2027
+ + ` const __cpuUserUs = (__agg.culprits || []).filter(c => c.location && c.location.indexOf(__userFile) === 0).reduce((a, c) => a + c.self_us, 0);\n`
2028
+ + ` const __wallUs = Math.round(__wallMs * 1000);\n`
2029
+ + ` const __mem = { rss_mb: Math.round(__memAfter.rss/10485.76)/100, heapUsed_mb: Math.round(__memAfter.heapUsed/10485.76)/100, heapUsed_delta_mb: Math.round((__memAfter.heapUsed-__memBefore.heapUsed)/10485.76)/100, external_mb: Math.round(__memAfter.external/10485.76)/100 };\n`
2030
+ + ` const __wallVsCpu = { wall_us: __wallUs, cpu_user_self_us: __cpuUserUs, cpu_total_sampled_us: __cpuTotalUs, offcpu_us: Math.max(0, __wallUs - __cpuUserUs), note: 'offcpu_us = inner wall minus on-CPU user-code JS self time = IO/async/GPU/idle the CPU sampler is blind to; cpu_total_sampled_us includes node-init/inspector overhead' };\n`
2031
+ + ` process.stdout.write('__GM_PROFILE__' + JSON.stringify({ result: __userResult, user_error: __userError, profile: __agg, profile_error: __profileError, mem: __mem, wall_vs_cpu: __wallVsCpu }));\n`
1961
2032
  + ` __session.disconnect();\n`
1962
2033
  + `})();\n`;
1963
2034
  cmd = process.execPath; args = ['-e', runnerCode];
@@ -1988,6 +2059,8 @@ function makeHostFunctions(instanceRef) {
1988
2059
  profile: parsed ? parsed.profile : { timeframe: null, culprits: [] },
1989
2060
  profile_error: parsed ? parsed.profile_error : 'profile sentinel not found in stdout',
1990
2061
  user_error: parsed ? parsed.user_error : null,
2062
+ mem: parsed ? parsed.mem : null,
2063
+ wall_vs_cpu: parsed ? parsed.wall_vs_cpu : null,
1991
2064
  });
1992
2065
  }
1993
2066
  return writeWasmJson(instanceRef.value, {
@@ -1997,6 +2070,7 @@ function makeHostFunctions(instanceRef) {
1997
2070
  exit_code: result.status === null ? -1 : result.status,
1998
2071
  timed_out: result.signal === 'SIGTERM',
1999
2072
  duration_ms: Date.now() - __execT0,
2073
+ ...(profileSkipped ? { profile_skipped: profileSkipped } : {}),
2000
2074
  });
2001
2075
  } catch (e) {
2002
2076
  return writeWasmJson(instanceRef.value, { ok: false, error: e.message });
@@ -2128,7 +2202,12 @@ function makeHostFunctions(instanceRef) {
2128
2202
  const gotoPrefix = startUrl
2129
2203
  ? `await page.goto(${JSON.stringify(startUrl)},{waitUntil:'load',timeout:${navTimeout}});\n`
2130
2204
  : '';
2131
- const modeMatch = evalBody.match(/^(capture|profile)[ \t]*\n([\s\S]*)$/);
2205
+ const modeMatch = evalBody.match(/^(capture|profile|trace)((?:[ \t]+(?:interval|topN)=\d+)*)[ \t]*\n([\s\S]*)$/);
2206
+ const modeOpts = modeMatch ? modeMatch[2] : '';
2207
+ const __intervalM = modeOpts.match(/interval=(\d+)/);
2208
+ const __topNM = modeOpts.match(/topN=(\d+)/);
2209
+ const sampleIntervalUs = __intervalM && parseInt(__intervalM[1], 10) > 0 ? parseInt(__intervalM[1], 10) : 100;
2210
+ const profileTopNBrowser = __topNM && parseInt(__topNM[1], 10) > 0 ? parseInt(__topNM[1], 10) : 20;
2132
2211
  const debugSetup = `const __logs=[],__errs=[],__net=[];\n`
2133
2212
  + `try{page.on('console',m=>{try{__logs.push({type:m.type(),text:m.text()});}catch(_){}});`
2134
2213
  + `page.on('pageerror',e=>{try{__errs.push({type:'pageerror',msg:String(e&&e.message||e)});}catch(_){}});`
@@ -2137,25 +2216,48 @@ function makeHostFunctions(instanceRef) {
2137
2216
  + `page.on('requestfailed',r=>{try{const err=r.failure();__errs.push({type:'fetch',msg:String(err&&err.errorText||'request failed'),url:String(r.url()).slice(0,120)});}catch(_){}});`
2138
2217
  + `page.evaluateOnNewDocument(()=>{window.__gmErrors=[];window.onerror=(msg,src,line,col,err)=>{try{window.__gmErrors.push({type:'error',msg:String(msg),src:String(src).slice(0,80),line,col,stack:String(err&&err.stack||'')});}catch(_){};return false;};window.onunhandledrejection=(e)=>{try{window.__gmErrors.push({type:'unhandledRejection',msg:String(e.reason&&e.reason.message||e.reason),stack:String(e.reason&&e.reason.stack||'')});}catch(_){}};});`
2139
2218
  + `}catch(_){}\n`;
2140
- const perfRead = `let __perf=null;try{__perf=await page.evaluate(()=>{const n=performance.getEntriesByType('navigation')[0];return n?{load_ms:Math.round(n.loadEventEnd||0),dcl_ms:Math.round(n.domContentLoadedEventEnd||0),resources:performance.getEntriesByType('resource').length,now:Math.round(performance.now())}:null;});}catch(_){}\n`;
2219
+ const perfRead = `let __perf=null;try{__perf=await page.evaluate(async()=>{const n=performance.getEntriesByType('navigation')[0];const paints={};for(const p of performance.getEntriesByType('paint')){paints[p.name]=Math.round(p.startTime);}let lcp=0;try{const le=performance.getEntriesByType('largest-contentful-paint');if(le.length)lcp=Math.round(le[le.length-1].startTime);}catch(_){}let cls=0;try{for(const ls of performance.getEntriesByType('layout-shift')){if(!ls.hadRecentInput)cls+=ls.value;}}catch(_){}let longtasks=0;try{longtasks=performance.getEntriesByType('longtask').length;}catch(_){}const fps=await new Promise(res=>{let f=0;const s=performance.now();function tick(){f++;if(performance.now()-s>=500)return res(Math.round(f/((performance.now()-s)/1000)));requestAnimationFrame(tick);}requestAnimationFrame(tick);});return{load_ms:n?Math.round(n.loadEventEnd||0):0,dcl_ms:n?Math.round(n.domContentLoadedEventEnd||0):0,resources:performance.getEntriesByType('resource').length,now:Math.round(performance.now()),first_paint_ms:paints['first-paint']||0,first_contentful_paint_ms:paints['first-contentful-paint']||0,largest_contentful_paint_ms:lcp,cumulative_layout_shift:Math.round(cls*1000)/1000,longtasks,fps};});}catch(_){}\n`;
2141
2220
  const blankProbe = startUrl ? '' : `try{const __u=page.url();if(__u==='about:blank'||__u===''){console.error('__GM_BLANK__');}}catch(_){}\n`;
2142
2221
  if (modeMatch && modeMatch[1] === 'profile') {
2143
- const userScript = modeMatch[2];
2144
- const intervalUs = 100;
2222
+ const userScript = modeMatch[3];
2223
+ const intervalUs = sampleIntervalUs;
2145
2224
  evalBody = debugSetup
2146
2225
  + `let __profile=null,__profileError=null;\n`
2147
2226
  + `let __cdp=null;\n`
2148
2227
  + `try{__cdp=await page.context().newCDPSession(page);await __cdp.send('Profiler.enable');await __cdp.send('Profiler.setSamplingInterval',{interval:${intervalUs}});await __cdp.send('Profiler.start');}catch(e){__profileError=String(e&&e.message||e);__cdp=null;}\n`
2228
+ + `const __wallT0=Date.now();\n`
2149
2229
  + `const __result = await (async () => {\n${blankProbe}${gotoPrefix}try{${userScript}}catch(e){__errs.push({type:'exec',msg:String(e&&e.message||e),stack:String(e&&e.stack||'')});throw e;}\n})();\n`
2230
+ + `const __wallUs=(Date.now()-__wallT0)*1000;\n`
2150
2231
  + `if(__cdp){try{const __r=await __cdp.send('Profiler.stop');__profile=__r&&__r.profile||null;}catch(e){__profileError=String(e&&e.message||e);}}\n`
2151
2232
  + `const __wmErrors=await page.evaluate(()=>window.__gmErrors||[]);\n`
2152
2233
  + perfRead
2153
2234
  + AGGREGATE_CPU_PROFILE_SRC + `\n`
2154
- + `const __agg = __profile ? aggregateCpuProfile(__profile) : {timeframe:null,culprits:[]};\n`
2235
+ + `const __agg = __profile ? aggregateCpuProfile(__profile, ${profileTopNBrowser}) : {timeframe:null,culprits:[]};\n`
2236
+ + `const __cpuUs=__agg.timeframe?__agg.timeframe.total_us:0;\n`
2237
+ + `const __wallVsCpu={wall_us:__wallUs,cpu_self_us:__cpuUs,offcpu_us:Math.max(0,__wallUs-__cpuUs),note:'offcpu_us = wall minus on-CPU JS self time = GPU/compositor/raster/IO/idle the CPU sampler is blind to; use trace mode to attribute GPU activity'};\n`
2238
+ + `const __allErrors=[...__errs,...__wmErrors];\n`
2239
+ + `return {result:__result,profile:__agg,profile_error:__profileError,wall_vs_cpu:__wallVsCpu,debug:{console:__logs,pageErrors:__allErrors,network:__net.slice(0,30),performance:__perf}};`;
2240
+ } else if (modeMatch && modeMatch[1] === 'trace') {
2241
+ const userScript = modeMatch[3];
2242
+ evalBody = debugSetup
2243
+ + `let __traceEvents=[],__traceError=null,__cdp=null,__traceComplete=false;\n`
2244
+ + `const __traceCats=['gpu','disabled-by-default-gpu.service','viz','cc','blink','devtools.timeline','toplevel','rail'];\n`
2245
+ + `try{__cdp=await page.context().newCDPSession(page);__cdp.on('Tracing.dataCollected',p=>{if(p&&p.value)__traceEvents.push(...p.value);});await __cdp.send('Tracing.start',{traceConfig:{includedCategories:__traceCats},transferMode:'ReportEvents',bufferUsageReportingInterval:0});}catch(e){__traceError='start:'+String(e&&e.message||e);__cdp=null;}\n`
2246
+ + `const __wallT0=Date.now();\n`
2247
+ + `const __result = await (async () => {\n${blankProbe}${gotoPrefix}try{${userScript}}catch(e){__errs.push({type:'exec',msg:String(e&&e.message||e),stack:String(e&&e.stack||'')});throw e;}\n})();\n`
2248
+ + `const __wallUs=(Date.now()-__wallT0)*1000;\n`
2249
+ + `if(__cdp){const __done=new Promise(res=>{__cdp.once('Tracing.tracingComplete',()=>res(true));setTimeout(()=>res(false),Math.min(${Math.min(navTimeout, 10000)},10000));});try{await __cdp.send('Tracing.end');}catch(e){__traceError=(__traceError||'')+' end:'+String(e&&e.message||e);}__traceComplete=await __done;}\n`
2250
+ + `const __wmErrors=await page.evaluate(()=>window.__gmErrors||[]);\n`
2251
+ + perfRead
2252
+ + `const __byCat={};let __minTs=Infinity,__maxTs=-Infinity;for(const ev of __traceEvents){if(typeof ev.ts==='number'){__minTs=Math.min(__minTs,ev.ts);if(typeof ev.dur==='number')__maxTs=Math.max(__maxTs,ev.ts+ev.dur);}if(typeof ev.dur==='number'&&ev.dur>0){const c=ev.cat||'?';__byCat[c]=(__byCat[c]||0)+ev.dur;}}\n`
2253
+ + `const __sum=(re)=>Object.entries(__byCat).filter(([k])=>re.test(k)).reduce((a,[,v])=>a+v,0);\n`
2254
+ + `const __gpuUs=__sum(/gpu|graphics\\.pipeline/),__vizUs=__sum(/viz/),__ccUs=__sum(/\\bcc\\b/),__rasterUs=__sum(/raster/);\n`
2255
+ + `const __topCats=Object.entries(__byCat).sort((a,b)=>b[1]-a[1]).slice(0,15).map(([cat,us])=>({cat,wall_us:us}));\n`
2256
+ + `const __spanUs=(isFinite(__minTs)&&__maxTs>0)?(__maxTs-__minTs):0;\n`
2155
2257
  + `const __allErrors=[...__errs,...__wmErrors];\n`
2156
- + `return {result:__result,profile:__agg,profile_error:__profileError,debug:{console:__logs,pageErrors:__allErrors,network:__net.slice(0,30),performance:__perf}};`;
2258
+ + `return {result:__result,trace:{wall_us:__wallUs,trace_span_us:__spanUs,event_count:__traceEvents.length,complete:__traceComplete,gpu_us:__gpuUs,viz_us:__vizUs,cc_us:__ccUs,raster_us:__rasterUs,offcpu_note:'gpu_us/viz_us/cc_us are wall-clock GPU-process activity (compositor/raster/draw) captured via CDP Tracing -- the CPU sampler cannot see these',by_category:__topCats},trace_error:__traceError,debug:{console:__logs,pageErrors:__allErrors,network:__net.slice(0,30),performance:__perf}};`;
2157
2259
  } else if (modeMatch && modeMatch[1] === 'capture') {
2158
- const userScript = modeMatch[2];
2260
+ const userScript = modeMatch[3];
2159
2261
  evalBody = debugSetup
2160
2262
  + `const __result = await (async () => {\n${blankProbe}${gotoPrefix}try{${userScript}}catch(e){__errs.push({type:'exec',msg:String(e&&e.message||e),stack:String(e&&e.stack||'')});throw e;}\n})();\n`
2161
2263
  + `const __wmErrors=await page.evaluate(()=>window.__gmErrors||[]);\n`