@houwert/conductor 0.13.0 → 0.14.0

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.
@@ -1,36 +1,58 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.HELP = void 0;
4
7
  exports.memory = memory;
5
- exports.HELP = ` memory [<appId>] Show device + app memory usage and object counts`;
8
+ exports.HELP = ` memory [<appId>] Show device + app memory usage
9
+ --objects Include per-class object counts (iOS: heap; slower)
10
+ --leaks Run leak detection (iOS only; slow, can pause the app)
11
+ --all Shorthand for --objects --leaks
12
+ --top <n> Limit object/region tables (default 20)
13
+ --save <name> Save the report as a snapshot
14
+ --diff <name> Diff snapshot <name> vs current
15
+ --diff <name> --vs <other> Diff two saved snapshots
16
+ --snapshots List saved snapshots
17
+ --no-gc Skip the pre-measurement GC (web only)
18
+ --filter <regex> Filter object/class tables by name (regex)
19
+ --growth-only In diff output, only show positive deltas (leak-hunting)
20
+ --json Emit JSON instead of formatted text`;
21
+ const node_fs_1 = require("node:fs");
22
+ const node_path_1 = __importDefault(require("node:path"));
23
+ const node_os_1 = __importDefault(require("node:os"));
6
24
  const runner_js_1 = require("../runner.js");
25
+ const sdk_js_1 = require("../android/sdk.js");
7
26
  const session_js_1 = require("../session.js");
27
+ const foreground_app_js_1 = require("./foreground-app.js");
8
28
  const output_js_1 = require("../output.js");
9
29
  const bootstrap_js_1 = require("../drivers/bootstrap.js");
10
30
  const ios_js_1 = require("../drivers/ios.js");
11
31
  const android_js_1 = require("../drivers/android.js");
12
32
  const web_js_1 = require("../drivers/web.js");
33
+ const hprof_js_1 = require("./hprof.js");
13
34
  async function resolveDeviceId(sessionName) {
14
35
  if (sessionName !== 'default')
15
36
  return sessionName;
16
37
  const session = await (0, session_js_1.getSession)(sessionName);
17
38
  return session.deviceId ?? (await (0, runner_js_1.detectFirstDevice)());
18
39
  }
19
- async function resolveAppId(explicit, sessionName) {
40
+ async function resolveAppId(explicit, sessionName, deviceId) {
20
41
  if (explicit)
21
42
  return explicit;
22
- const session = await (0, session_js_1.getSession)(sessionName);
23
- if (session.appId)
24
- return session.appId;
25
- // Fall back to whatever's in the foreground.
43
+ // No arg: always resolve from the live foreground app, not the session file.
44
+ // The session's appId reflects the last `launch-app` call, which can be stale
45
+ // if the user switched apps on the device by other means.
26
46
  try {
27
47
  const driver = await (0, runner_js_1.getDriver)(sessionName);
28
48
  if (driver instanceof android_js_1.AndroidDriver)
29
49
  return await driver.getForegroundApp();
30
50
  if (driver instanceof web_js_1.WebDriver)
31
51
  return await driver.runningApp();
32
- if (driver instanceof ios_js_1.IOSDriver)
33
- return await driver.runningApp([]);
52
+ if (driver instanceof ios_js_1.IOSDriver) {
53
+ const appIds = await (0, foreground_app_js_1.getInstalledAppIds)(deviceId);
54
+ return await driver.runningApp(appIds);
55
+ }
34
56
  }
35
57
  catch {
36
58
  /* ignore */
@@ -58,7 +80,6 @@ function parseAndroidDumpsysMeminfo(out) {
58
80
  const pidMatch = out.match(/\*\* MEMINFO in pid (\d+)/);
59
81
  const pid = pidMatch ? Number(pidMatch[1]) : undefined;
60
82
  // App Summary block — most useful, single-line entries with KB values.
61
- // e.g. " Java Heap: 12345"
62
83
  const summary = out.match(/App Summary[\s\S]*?(?:\n\s*\n|TOTAL:)/);
63
84
  if (summary) {
64
85
  const block = summary[0];
@@ -83,25 +104,14 @@ function parseAndroidDumpsysMeminfo(out) {
83
104
  if (totalSwap)
84
105
  app['totalSwapBytes'] = Number(totalSwap[1]) * 1024;
85
106
  }
86
- // Fallback: TOTAL line in the main table — "TOTAL 12345 ..."
87
107
  if (app.totalPssBytes === undefined) {
88
108
  const total = out.match(/^\s*TOTAL\s+(\d+)/m);
89
109
  if (total)
90
110
  app.totalPssBytes = Number(total[1]) * 1024;
91
111
  }
92
- // Objects block:
93
- // Objects
94
- // Views: 3 ViewRootImpl: 1
95
- // AppContexts: 2 Activities: 1
96
- // Assets: 7 AssetManagers: 0
97
- // Local Binders: 5 Proxy Binders: 17
98
- // Parcel memory: 1 Parcel count: 4
99
- // Death Recipients: 0 OpenSSL Sockets: 0
100
- // WebViews: 0
101
112
  const objSection = out.match(/Objects[\s\S]*?(?:\n\s*\n|SQL\s|DATABASES|$)/);
102
113
  if (objSection) {
103
114
  const text = objSection[0];
104
- // Capture every "Label: number" pair (labels may have spaces).
105
115
  const re = /([A-Za-z][A-Za-z _]*?):\s+(\d+)/g;
106
116
  let m;
107
117
  while ((m = re.exec(text)) !== null) {
@@ -113,9 +123,57 @@ function parseAndroidDumpsysMeminfo(out) {
113
123
  }
114
124
  return { app, objects, pid };
115
125
  }
116
- async function collectAndroid(deviceId, appId) {
126
+ function parseAndroidUnreachable(out) {
127
+ // dumpsys meminfo --unreachable <pid> output:
128
+ // Unreachable memory:
129
+ // 1234 bytes in 5 unreachable allocations
130
+ // ABI: 'arm64'
131
+ //
132
+ // 192 bytes unreachable at 12abcdef
133
+ // first 32 bytes of contents:
134
+ // ...
135
+ // #00 pc 000... /system/lib64/libc.so (malloc+24)
136
+ // #01 pc 000... /data/app/.../lib/libfoo.so (foo_init+32)
137
+ const summary = out.match(/(\d+)\s+bytes?\s+in\s+(\d+)\s+unreachable\s+allocations?/i);
138
+ const totalBytes = summary ? Number(summary[1]) : 0;
139
+ const totalCount = summary ? Number(summary[2]) : 0;
140
+ // Aggregate by the most-specific (last user) library frame in each backtrace.
141
+ // System libs (libc, liblog, libart) are usually the immediate allocator —
142
+ // the user library a frame or two up tells us what's actually leaking.
143
+ const byClass = new Map();
144
+ const blocks = out.split(/(?=^\s*\d+\s+bytes?\s+unreachable\s+at)/m);
145
+ for (const block of blocks) {
146
+ const head = block.match(/^\s*(\d+)\s+bytes?\s+unreachable\s+at/m);
147
+ if (!head)
148
+ continue;
149
+ const size = Number(head[1]);
150
+ // Find first non-libc/-liblog/-libart frame in the backtrace.
151
+ const frames = [...block.matchAll(/#\d+\s+pc\s+\S+\s+(\/\S+?\.so)\s+\(([^)+]+)/g)];
152
+ let owner = '<unknown>';
153
+ for (const f of frames) {
154
+ const lib = f[1];
155
+ const sym = f[2];
156
+ if (/lib(c|m|art|log|dl|cutils)\.so$/.test(lib))
157
+ continue;
158
+ owner = `${sym} [${lib.split('/').pop()}]`;
159
+ break;
160
+ }
161
+ if (owner === '<unknown>' && frames[0]) {
162
+ owner = `${frames[0][2]} [${frames[0][1].split('/').pop()}]`;
163
+ }
164
+ const prev = byClass.get(owner) ?? { count: 0, bytes: 0 };
165
+ prev.count += 1;
166
+ prev.bytes += size;
167
+ byClass.set(owner, prev);
168
+ }
169
+ const classes = [...byClass.entries()]
170
+ .map(([cls, v]) => ({ class: cls, count: v.count, bytes: v.bytes }))
171
+ .sort((a, b) => b.bytes - a.bytes);
172
+ return { totalCount, totalBytes, classes };
173
+ }
174
+ async function collectAndroid(deviceId, appId, opts) {
117
175
  const report = { platform: 'android', deviceId, appId, notes: [] };
118
- const meminfo = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'cat', '/proc/meminfo']);
176
+ const meminfo = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', deviceId, 'shell', 'cat', '/proc/meminfo'], { env: (0, sdk_js_1.androidSpawnEnv)() });
119
177
  if (meminfo.success) {
120
178
  const sys = parseAndroidMeminfo(meminfo.stdout);
121
179
  report.system = {
@@ -129,7 +187,7 @@ async function collectAndroid(deviceId, appId) {
129
187
  report.notes.push(`/proc/meminfo unavailable: ${meminfo.stderr.trim()}`);
130
188
  }
131
189
  if (appId) {
132
- const dump = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'dumpsys', 'meminfo', appId]);
190
+ const dump = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', deviceId, 'shell', 'dumpsys', 'meminfo', appId], { env: (0, sdk_js_1.androidSpawnEnv)() });
133
191
  if (dump.success && !dump.stdout.includes('No process found')) {
134
192
  const { app, objects, pid } = parseAndroidDumpsysMeminfo(dump.stdout);
135
193
  report.app = app;
@@ -139,6 +197,80 @@ async function collectAndroid(deviceId, appId) {
139
197
  else {
140
198
  report.notes.push(`dumpsys meminfo ${appId} returned no data — app may not be running`);
141
199
  }
200
+ // --leaks → dumpsys meminfo --unreachable <pid>. Requires root on stock
201
+ // images; on user builds it returns "Unreachable memory check not supported".
202
+ if (opts.leaks && report.pid) {
203
+ const unreach = await (0, runner_js_1.spawnCommand)('adb', [
204
+ '-s',
205
+ deviceId,
206
+ 'shell',
207
+ 'dumpsys',
208
+ 'meminfo',
209
+ '--unreachable',
210
+ String(report.pid),
211
+ ]);
212
+ if (unreach.success && unreach.stdout.includes('Unreachable memory:')) {
213
+ report.leaks = parseAndroidUnreachable(unreach.stdout);
214
+ }
215
+ else if (unreach.stdout.includes('not supported')) {
216
+ report.notes.push('dumpsys --unreachable not supported on this build (needs userdebug/root).');
217
+ }
218
+ else {
219
+ report.notes.push(`--unreachable unavailable: ${unreach.stderr.trim() || 'no output'}`);
220
+ }
221
+ }
222
+ // --objects → trigger `am dumpheap` and pull the .hprof. We don't parse
223
+ // HPROF binary; the file is meant to be opened in Android Studio's Memory
224
+ // Profiler. The path is recorded so it shows up in the report notes.
225
+ if (opts.objects && report.pid) {
226
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
227
+ const remote = `/data/local/tmp/conductor-${appId}-${ts}.hprof`;
228
+ const dumpRes = await (0, runner_js_1.spawnCommand)('adb', [
229
+ '-s',
230
+ deviceId,
231
+ 'shell',
232
+ 'am',
233
+ 'dumpheap',
234
+ String(report.pid),
235
+ remote,
236
+ ]);
237
+ if (dumpRes.success) {
238
+ await new Promise((r) => setTimeout(r, 1500)); // dumpheap is async; wait for flush
239
+ const localDir = node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'heap-dumps');
240
+ await node_fs_1.promises.mkdir(localDir, { recursive: true });
241
+ const localFile = node_path_1.default.join(localDir, node_path_1.default.basename(remote));
242
+ const pull = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'pull', remote, localFile]);
243
+ if (pull.success) {
244
+ await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'rm', remote]);
245
+ // Parse the HPROF binary for per-class instance counts/bytes — same
246
+ // shape as iOS `heap` and Web V8 snapshot output, so it slots into
247
+ // the existing snapshot/diff workflow.
248
+ try {
249
+ const buf = await node_fs_1.promises.readFile(localFile);
250
+ const { classes, totals, heaps } = (0, hprof_js_1.parseHprof)(buf);
251
+ if (classes.length > 0)
252
+ report.objectClasses = classes;
253
+ report.heapTotals = totals;
254
+ const heapsNote = heaps
255
+ ? ' ' +
256
+ Object.entries(heaps)
257
+ .map(([h, v]) => `${h}: ${v.count.toLocaleString()} obj / ${(v.bytes / 1048576).toFixed(1)} MB`)
258
+ .join(', ')
259
+ : '';
260
+ report.notes.push(`Heap dump saved → ${localFile}${heapsNote} (also openable in Android Studio: Profiler → Memory → Import Heap Dump)`);
261
+ }
262
+ catch (err) {
263
+ report.notes.push(`Heap dump saved → ${localFile} but parse failed: ${err instanceof Error ? err.message : String(err)}`);
264
+ }
265
+ }
266
+ else {
267
+ report.notes.push(`adb pull heap dump failed: ${pull.stderr.trim()}`);
268
+ }
269
+ }
270
+ else {
271
+ report.notes.push(`am dumpheap failed: ${dumpRes.stderr.trim()}`);
272
+ }
273
+ }
142
274
  }
143
275
  if (report.notes.length === 0)
144
276
  delete report.notes;
@@ -146,7 +278,6 @@ async function collectAndroid(deviceId, appId) {
146
278
  }
147
279
  // ── iOS / tvOS ────────────────────────────────────────────────────────────────
148
280
  function parseVmStat(out) {
149
- // vm_stat output uses "page size of 16384 bytes" and counts in pages.
150
281
  const pageMatch = out.match(/page size of (\d+)/);
151
282
  const pageSize = pageMatch ? Number(pageMatch[1]) : 4096;
152
283
  const grab = (label) => {
@@ -186,23 +317,40 @@ async function findIOSPid(deviceId, appId) {
186
317
  }
187
318
  return undefined;
188
319
  }
320
+ function parseFootprint(out) {
321
+ // Header: "Plex [15843]: 64-bit Footprint: 801 MB (16384 bytes per page)"
322
+ // Values can be "B", "KB", "MB", "GB" with a space between number and unit.
323
+ const numUnit = '([\\d.]+\\s*[KMGT]?B)';
324
+ const header = out.match(new RegExp(`Footprint:\\s+${numUnit}`));
325
+ const footprintBytes = header ? humanToBytes(header[1].replace(/\s+/g, '')) : undefined;
326
+ // TOTAL line: " 801 MB 70 MB 4960 KB 5577 TOTAL"
327
+ // Columns: Dirty | Clean | Reclaimable | Regions | "TOTAL"
328
+ const total = out.match(new RegExp(`^\\s*${numUnit}\\s+${numUnit}\\s+${numUnit}\\s+\\d+\\s+TOTAL\\s*$`, 'm'));
329
+ const dirtyBytes = total ? humanToBytes(total[1].replace(/\s+/g, '')) : undefined;
330
+ return { footprintBytes, dirtyBytes };
331
+ }
189
332
  function parseVmmapSummary(out) {
190
333
  const regions = {};
191
334
  const app = {};
192
- // Lines look like: "MALLOC 1.2G 234.5M ..."
193
- // We want the "RESIDENT" column (2nd numeric). The columns are whitespace-delimited.
194
- // Easier: parse the per-region rows under "REGION TYPE" header.
335
+ // Find the per-region body. Layout:
336
+ // VIRTUAL RESIDENT DIRTY ...
337
+ // REGION TYPE SIZE SIZE SIZE ...
338
+ // =========== ======= ======== ===== ...
339
+ // Accelerate framework 128K 128K 128K ...
340
+ // ...
341
+ // TOTAL 5.2G 1.4G 950M ...
195
342
  const headerIdx = out.indexOf('REGION TYPE');
196
343
  if (headerIdx === -1)
197
344
  return { app, regions };
198
- const body = out.slice(headerIdx);
199
- const lines = body.split('\n').slice(1);
200
- for (const raw of lines) {
201
- const line = raw.trimEnd();
202
- if (!line.trim())
345
+ const lines = out.slice(headerIdx).split('\n');
346
+ // Skip the REGION TYPE header line and the ====== separator that follows.
347
+ for (let i = 1; i < lines.length; i++) {
348
+ const line = lines[i].trimEnd();
349
+ const trimmed = line.trim();
350
+ if (!trimmed)
203
351
  continue;
204
- if (line.startsWith('='))
205
- break;
352
+ if (trimmed.startsWith('='))
353
+ continue; // separator row
206
354
  if (line.startsWith('TOTAL')) {
207
355
  // "TOTAL 5.2G 1.4G 950M ..."
208
356
  const m = line.match(/TOTAL\s+\S+\s+(\S+)/);
@@ -219,13 +367,12 @@ function parseVmmapSummary(out) {
219
367
  if (resident !== undefined)
220
368
  regions[name] = resident;
221
369
  }
222
- // Roll up a few well-known regions into the canonical fields.
223
370
  app.nativeHeapBytes = regions['MALLOC'] ?? regions['MALLOC_NANO'];
224
371
  app.stackBytes = regions['Stack'];
225
372
  return { app, regions };
226
373
  }
227
374
  function humanToBytes(s) {
228
- // Matches "1.2G", "234.5M", "950K", "1024", "1.2GB" etc.
375
+ // Matches "1.2G", "234.5M", "950K", "1024", "1.2GB", "0K" etc.
229
376
  const m = s.match(/^([\d.]+)\s*([KMGT]?)B?$/i);
230
377
  if (!m)
231
378
  return undefined;
@@ -241,10 +388,104 @@ function humanToBytes(s) {
241
388
  };
242
389
  return Math.round(n * (mult[m[2].toUpperCase()] ?? 1));
243
390
  }
244
- async function collectIOS(deviceId, platform, appId) {
391
+ // ── iOS heap (per-class object counts) ────────────────────────────────────────
392
+ function parseHeap(out) {
393
+ const classes = [];
394
+ let totals;
395
+ // Locate the "All zones: N nodes (B bytes)" totals line.
396
+ const totalsMatch = out.match(/All zones:\s+(\d+)\s+nodes\s+\((\d+)\s+bytes\)/);
397
+ if (totalsMatch) {
398
+ totals = { count: Number(totalsMatch[1]), bytes: Number(totalsMatch[2]) };
399
+ }
400
+ // Find the table header row. Heap prints:
401
+ // COUNT BYTES AVG CLASS_NAME ... TYPE BINARY
402
+ // ===== ===== === ========== ... ==== ======
403
+ // 549284 450043193 819.3 non-object
404
+ // 33305 1813840 54.5 CFString ObjC CoreFoundation
405
+ const headerIdx = out.search(/^\s*COUNT\s+BYTES\s+AVG\s+CLASS_NAME/m);
406
+ if (headerIdx === -1)
407
+ return { classes, totals };
408
+ const body = out.slice(headerIdx).split('\n').slice(2); // skip header + ===
409
+ for (const raw of body) {
410
+ const line = raw.trimEnd();
411
+ const trimmed = line.trim();
412
+ if (!trimmed)
413
+ break; // blank line ends the table
414
+ if (trimmed.startsWith('='))
415
+ continue;
416
+ // Match: leading count, bytes, avg, then the rest. CLASS_NAME / TYPE / BINARY
417
+ // are space-separated but the class name itself can contain spaces and angle
418
+ // brackets, so split off the trailing TYPE+BINARY columns from the right.
419
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(.*)$/);
420
+ if (!m)
421
+ continue;
422
+ const count = Number(m[1]);
423
+ const bytes = Number(m[2]);
424
+ const rest = m[4];
425
+ // Trailing two whitespace-separated tokens are TYPE and BINARY (BINARY may
426
+ // be missing for "non-object" entries). Detect by looking at the last 1-2
427
+ // tokens; if the last token looks like a known TYPE, BINARY is absent.
428
+ const KNOWN_TYPES = new Set(['ObjC', 'Swift', 'C', 'C++', 'CFType']);
429
+ let className = rest;
430
+ let type;
431
+ let binary;
432
+ // Try: "<class> TYPE BINARY"
433
+ const trail2 = rest.match(/^(.*?)\s{2,}(\S+)\s+(\S+)\s*$/);
434
+ if (trail2 && KNOWN_TYPES.has(trail2[2])) {
435
+ className = trail2[1].trim();
436
+ type = trail2[2];
437
+ binary = trail2[3];
438
+ }
439
+ else {
440
+ // Try: "<class> TYPE" (no binary)
441
+ const trail1 = rest.match(/^(.*?)\s{2,}(\S+)\s*$/);
442
+ if (trail1 && KNOWN_TYPES.has(trail1[2])) {
443
+ className = trail1[1].trim();
444
+ type = trail1[2];
445
+ }
446
+ else {
447
+ className = rest.trim();
448
+ }
449
+ }
450
+ if (!className)
451
+ continue;
452
+ classes.push({ class: className, count, bytes, type, binary });
453
+ }
454
+ classes.sort((a, b) => b.bytes - a.bytes);
455
+ return { classes, totals };
456
+ }
457
+ // ── iOS leaks ─────────────────────────────────────────────────────────────────
458
+ function parseLeaks(out) {
459
+ // `leaks` prints e.g.:
460
+ // Process 12345: 42 leaks for 16384 total leaked bytes.
461
+ // Followed by per-leak detail lines:
462
+ // Leak: 0x12345 size=128 zone: ... Class: NSConcreteData
463
+ // We aggregate by class.
464
+ const summary = out.match(/(\d+)\s+leaks?\s+for\s+(\d+)\s+total\s+leaked\s+bytes/i);
465
+ const totalCount = summary ? Number(summary[1]) : 0;
466
+ const totalBytes = summary ? Number(summary[2]) : 0;
467
+ const byClass = new Map();
468
+ const re = /Leak:\s*0x[0-9a-f]+\s+size=(\d+)[^\n]*?(?:\s(?:Class|Type):\s*(\S+))?/gi;
469
+ let m;
470
+ while ((m = re.exec(out)) !== null) {
471
+ const size = Number(m[1]);
472
+ const cls = m[2] ?? '<unknown>';
473
+ const prev = byClass.get(cls) ?? { count: 0, bytes: 0 };
474
+ prev.count += 1;
475
+ prev.bytes += size;
476
+ byClass.set(cls, prev);
477
+ }
478
+ const classes = [...byClass.entries()]
479
+ .map(([cls, v]) => ({ class: cls, count: v.count, bytes: v.bytes }))
480
+ .sort((a, b) => b.bytes - a.bytes);
481
+ return { totalCount, totalBytes, classes };
482
+ }
483
+ async function collectIOS(deviceId, platform, appId, opts) {
245
484
  const report = { platform, deviceId, appId, notes: [] };
246
- // System-wide memory: vm_stat from inside the simulator (== host RAM).
247
- const vm = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'spawn', deviceId, 'vm_stat']);
485
+ // System-wide memory: vm_stat from the host. Simulators share host RAM, and
486
+ // `vm_stat` is a host macOS binary — it isn't present inside the simulator
487
+ // runtime, so `xcrun simctl spawn <id> vm_stat` fails with ENOENT.
488
+ const vm = await (0, runner_js_1.spawnCommand)('vm_stat', []);
248
489
  if (vm.success) {
249
490
  const sys = parseVmStat(vm.stdout);
250
491
  report.system = {
@@ -284,7 +525,19 @@ async function collectIOS(deviceId, platform, appId) {
284
525
  };
285
526
  }
286
527
  }
287
- // vmmap -summary for region breakdown slower but provides categories.
528
+ // footprint phys footprint + dirty memory totals. This is the actionable
529
+ // OOM number on iOS (jetsam compares this against the per-app limit), so
530
+ // surface it before vmmap region detail.
531
+ const fp = await (0, runner_js_1.spawnCommand)('footprint', [String(pid)]);
532
+ if (fp.success) {
533
+ const { footprintBytes, dirtyBytes } = parseFootprint(fp.stdout);
534
+ report.app = {
535
+ ...(report.app ?? {}),
536
+ ...(footprintBytes !== undefined ? { footprintBytes } : {}),
537
+ ...(dirtyBytes !== undefined ? { dirtyBytes } : {}),
538
+ };
539
+ }
540
+ // vmmap -summary for region breakdown.
288
541
  const vmmap = await (0, runner_js_1.spawnCommand)('vmmap', ['-summary', String(pid)]);
289
542
  if (vmmap.success) {
290
543
  const { app, regions } = parseVmmapSummary(vmmap.stdout);
@@ -295,14 +548,91 @@ async function collectIOS(deviceId, platform, appId) {
295
548
  };
296
549
  }
297
550
  else {
298
- report.notes.push('vmmap unavailable run `sudo DevToolsSecurity --enable` if it errors.');
551
+ report.notes.push(`vmmap unavailable: ${vmmap.stderr.trim() || 'unknown error'}`);
552
+ }
553
+ // heap for per-class object counts/bytes.
554
+ if (opts.objects) {
555
+ const h = await (0, runner_js_1.spawnCommand)('heap', [String(pid)]);
556
+ if (h.success) {
557
+ const { classes, totals } = parseHeap(h.stdout);
558
+ if (classes.length > 0)
559
+ report.objectClasses = classes;
560
+ if (totals)
561
+ report.heapTotals = totals;
562
+ }
563
+ else {
564
+ report.notes.push(`heap unavailable: ${h.stderr.trim() || 'unknown error'}`);
565
+ }
566
+ }
567
+ // leaks for leak detection.
568
+ if (opts.leaks) {
569
+ const l = await (0, runner_js_1.spawnCommand)('leaks', [String(pid)]);
570
+ // `leaks` exits non-zero when leaks are found; treat any output as success.
571
+ if (l.stdout && l.stdout.includes('leaks for')) {
572
+ report.leaks = parseLeaks(l.stdout);
573
+ }
574
+ else if (l.success) {
575
+ report.leaks = { totalCount: 0, totalBytes: 0, classes: [] };
576
+ }
577
+ else {
578
+ report.notes.push(`leaks unavailable: ${l.stderr.trim() || 'unknown error'}`);
579
+ }
299
580
  }
300
581
  if (report.notes.length === 0)
301
582
  delete report.notes;
302
583
  return report;
303
584
  }
304
- // ── Web (Playwright via CDP) ──────────────────────────────────────────────────
305
- async function collectWeb(deviceId, sessionName) {
585
+ function parseV8HeapSnapshot(snap) {
586
+ const meta = snap.snapshot.meta;
587
+ const nf = meta.node_fields;
588
+ const stride = nf.length;
589
+ const typeIdx = nf.indexOf('type');
590
+ const nameIdx = nf.indexOf('name');
591
+ const sizeIdx = nf.indexOf('self_size');
592
+ const typeNames = meta.node_types[0]; // e.g. ["hidden","array","string","object","code","closure","regexp","number","native","synthetic","concatenated string","sliced string","symbol","bigint"]
593
+ const nodes = snap.nodes;
594
+ const strings = snap.strings;
595
+ // For "object", "closure", "native" → name is the constructor / function /
596
+ // C++ class name. For other types, group under "<type>".
597
+ const namedTypes = new Set(['object', 'closure', 'native']);
598
+ const namedTypeIds = new Set();
599
+ typeNames.forEach((t, i) => {
600
+ if (namedTypes.has(t))
601
+ namedTypeIds.add(i);
602
+ });
603
+ const byClass = new Map();
604
+ let totalCount = 0;
605
+ let totalBytes = 0;
606
+ for (let i = 0; i < nodes.length; i += stride) {
607
+ const t = nodes[i + typeIdx];
608
+ const size = nodes[i + sizeIdx];
609
+ const nameId = nodes[i + nameIdx];
610
+ totalCount++;
611
+ totalBytes += size;
612
+ let label;
613
+ if (namedTypeIds.has(t)) {
614
+ const tName = typeNames[t];
615
+ const ctor = strings[nameId] || '<anonymous>';
616
+ label = tName === 'object' ? ctor : `${ctor} [${tName}]`;
617
+ }
618
+ else {
619
+ label = `<${typeNames[t] ?? 'unknown'}>`;
620
+ }
621
+ const prev = byClass.get(label);
622
+ if (prev) {
623
+ prev.count++;
624
+ prev.bytes += size;
625
+ }
626
+ else {
627
+ byClass.set(label, { count: 1, bytes: size });
628
+ }
629
+ }
630
+ const classes = [...byClass.entries()]
631
+ .map(([cls, v]) => ({ class: cls, count: v.count, bytes: v.bytes }))
632
+ .sort((a, b) => b.bytes - a.bytes);
633
+ return { classes, totals: { count: totalCount, bytes: totalBytes } };
634
+ }
635
+ async function collectWeb(deviceId, sessionName, opts) {
306
636
  const report = { platform: 'web', deviceId, notes: [] };
307
637
  let driver;
308
638
  try {
@@ -330,11 +660,8 @@ async function collectWeb(deviceId, sessionName) {
330
660
  const m = data.metrics;
331
661
  const pm = data.pageMemory;
332
662
  report.app = {
333
- // Heap totals — prefer page-context performance.memory (Chromium) over
334
- // CDP's JSHeapUsedSize (which can lag). Both are JS heap only.
335
663
  nativeHeapBytes: pm?.usedJSHeapSize ?? m['JSHeapUsedSize'],
336
664
  codeBytes: m['JSHeapTotalSize'] ? m['JSHeapTotalSize'] - (m['JSHeapUsedSize'] ?? 0) : undefined,
337
- // Roll the raw CDP metrics into `regions` so they're inspectable verbatim.
338
665
  regions: { ...m },
339
666
  };
340
667
  if (pm) {
@@ -345,7 +672,6 @@ async function collectWeb(deviceId, sessionName) {
345
672
  'JS Heap Limit': pm.jsHeapSizeLimit,
346
673
  };
347
674
  }
348
- // Object counts — direct CDP equivalents of Android's "Views/Activities/Binders".
349
675
  const objectKeys = [
350
676
  'Nodes',
351
677
  'Documents',
@@ -361,31 +687,228 @@ async function collectWeb(deviceId, sessionName) {
361
687
  }
362
688
  if (Object.keys(objects).length > 0)
363
689
  report.objects = objects;
690
+ // --objects → take a real V8 heap snapshot, parse class counts/bytes, and
691
+ // save the .heapsnapshot file so it can be opened in Chrome DevTools.
692
+ if (opts.objects) {
693
+ try {
694
+ const snapText = await driver.heapSnapshot({ gc: opts.gc !== false });
695
+ const snap = JSON.parse(snapText);
696
+ const { classes, totals } = parseV8HeapSnapshot(snap);
697
+ if (classes.length > 0)
698
+ report.objectClasses = classes;
699
+ report.heapTotals = totals;
700
+ const dumpsDir = node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'heap-dumps');
701
+ await node_fs_1.promises.mkdir(dumpsDir, { recursive: true });
702
+ const safeUrl = (data.url || 'page').replace(/[^\w.-]+/g, '_').slice(0, 60);
703
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
704
+ const file = node_path_1.default.join(dumpsDir, `${safeUrl}-${ts}.heapsnapshot`);
705
+ await node_fs_1.promises.writeFile(file, snapText);
706
+ report.notes.push(`Heap snapshot saved → ${file} (open in Chrome DevTools: Memory → Load profile)`);
707
+ }
708
+ catch (err) {
709
+ report.notes.push(`Heap snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
710
+ }
711
+ }
364
712
  report.notes.push('Web memory is per-page (Performance.getMetrics + performance.memory). Run-wide RSS for the browser process is not exposed via CDP.');
365
713
  if (report.notes.length === 0)
366
714
  delete report.notes;
367
715
  return report;
368
716
  }
717
+ // ── Snapshot save / load / diff ───────────────────────────────────────────────
718
+ function snapshotsDir() {
719
+ return node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'memory-snapshots');
720
+ }
721
+ function snapshotPath(name) {
722
+ // Allow callers to pass either a bare name or a filename / path.
723
+ if (name.endsWith('.json') || name.includes('/'))
724
+ return name;
725
+ return node_path_1.default.join(snapshotsDir(), `${name}.json`);
726
+ }
727
+ async function saveSnapshot(name, report) {
728
+ const dir = snapshotsDir();
729
+ await node_fs_1.promises.mkdir(dir, { recursive: true });
730
+ const file = snapshotPath(name);
731
+ await node_fs_1.promises.writeFile(file, JSON.stringify(report, null, 2));
732
+ return file;
733
+ }
734
+ async function loadSnapshot(name) {
735
+ const file = snapshotPath(name);
736
+ const raw = await node_fs_1.promises.readFile(file, 'utf8');
737
+ return JSON.parse(raw);
738
+ }
739
+ async function listSnapshots() {
740
+ const dir = snapshotsDir();
741
+ let files;
742
+ try {
743
+ files = await node_fs_1.promises.readdir(dir);
744
+ }
745
+ catch {
746
+ return [];
747
+ }
748
+ const out = [];
749
+ for (const f of files) {
750
+ if (!f.endsWith('.json'))
751
+ continue;
752
+ const file = node_path_1.default.join(dir, f);
753
+ try {
754
+ const stat = await node_fs_1.promises.stat(file);
755
+ const r = JSON.parse(await node_fs_1.promises.readFile(file, 'utf8'));
756
+ out.push({
757
+ name: f.replace(/\.json$/, ''),
758
+ capturedAt: r.capturedAt,
759
+ appId: r.appId,
760
+ platform: r.platform,
761
+ size: stat.size,
762
+ });
763
+ }
764
+ catch {
765
+ /* skip malformed */
766
+ }
767
+ }
768
+ out.sort((a, b) => (a.capturedAt ?? '').localeCompare(b.capturedAt ?? ''));
769
+ return out;
770
+ }
771
+ function diffNumberMap(a, b) {
772
+ const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
773
+ const out = [];
774
+ for (const k of keys) {
775
+ const before = a?.[k] ?? 0;
776
+ const after = b?.[k] ?? 0;
777
+ if (before === after)
778
+ continue;
779
+ out.push({ key: k, before, after, delta: after - before });
780
+ }
781
+ out.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
782
+ return out;
783
+ }
784
+ function diffClasses(a, b) {
785
+ const idx = (arr) => {
786
+ const m = new Map();
787
+ for (const c of arr ?? [])
788
+ m.set(c.class, c);
789
+ return m;
790
+ };
791
+ const A = idx(a);
792
+ const B = idx(b);
793
+ const keys = new Set([...A.keys(), ...B.keys()]);
794
+ const out = [];
795
+ for (const k of keys) {
796
+ const x = A.get(k);
797
+ const y = B.get(k);
798
+ const beforeCount = x?.count ?? 0;
799
+ const afterCount = y?.count ?? 0;
800
+ const beforeBytes = x?.bytes ?? 0;
801
+ const afterBytes = y?.bytes ?? 0;
802
+ if (beforeCount === afterCount && beforeBytes === afterBytes)
803
+ continue;
804
+ out.push({
805
+ class: k,
806
+ beforeCount,
807
+ afterCount,
808
+ beforeBytes,
809
+ afterBytes,
810
+ deltaCount: afterCount - beforeCount,
811
+ deltaBytes: afterBytes - beforeBytes,
812
+ });
813
+ }
814
+ out.sort((x, y) => Math.abs(y.deltaBytes) - Math.abs(x.deltaBytes));
815
+ return out;
816
+ }
817
+ function buildDiff(a, b, aLabel, bLabel) {
818
+ const numericApp = (r) => {
819
+ const out = {};
820
+ for (const [k, v] of Object.entries(r.app ?? {})) {
821
+ if (typeof v === 'number')
822
+ out[k] = v;
823
+ }
824
+ return out;
825
+ };
826
+ return {
827
+ before: { name: aLabel, capturedAt: a.capturedAt },
828
+ after: { name: bLabel, capturedAt: b.capturedAt },
829
+ app: diffNumberMap(numericApp(a), numericApp(b)),
830
+ regions: diffNumberMap(a.app?.regions, b.app?.regions),
831
+ objects: diffNumberMap(a.objects, b.objects),
832
+ objectClasses: diffClasses(a.objectClasses, b.objectClasses),
833
+ leaksDelta: a.leaks || b.leaks
834
+ ? {
835
+ count: (b.leaks?.totalCount ?? 0) - (a.leaks?.totalCount ?? 0),
836
+ bytes: (b.leaks?.totalBytes ?? 0) - (a.leaks?.totalBytes ?? 0),
837
+ }
838
+ : undefined,
839
+ };
840
+ }
369
841
  // ── Formatting ────────────────────────────────────────────────────────────────
370
842
  function fmtBytes(n) {
371
843
  if (n === undefined)
372
844
  return '—';
373
- if (n < 1024)
374
- return `${n} B`;
845
+ const sign = n < 0 ? '-' : '';
846
+ const abs = Math.abs(n);
847
+ if (abs < 1024)
848
+ return `${sign}${abs} B`;
375
849
  const units = ['KB', 'MB', 'GB', 'TB'];
376
- let v = n / 1024;
850
+ let v = abs / 1024;
377
851
  let i = 0;
378
852
  while (v >= 1024 && i < units.length - 1) {
379
853
  v /= 1024;
380
854
  i++;
381
855
  }
382
- return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
856
+ return `${sign}${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
383
857
  }
384
- function formatReport(r) {
858
+ function fmtSignedBytes(n) {
859
+ if (n === 0)
860
+ return '±0';
861
+ return (n > 0 ? '+' : '') + fmtBytes(n);
862
+ }
863
+ function fmtSignedInt(n) {
864
+ if (n === 0)
865
+ return '±0';
866
+ return (n > 0 ? '+' : '') + String(n);
867
+ }
868
+ function makeFilter(pattern) {
869
+ if (!pattern)
870
+ return undefined;
871
+ try {
872
+ const re = new RegExp(pattern, 'i');
873
+ return (s) => re.test(s);
874
+ }
875
+ catch {
876
+ // Invalid regex → no filter (safer than throwing mid-render).
877
+ return undefined;
878
+ }
879
+ }
880
+ function filterClasses(arr, pattern) {
881
+ const f = makeFilter(pattern);
882
+ return f ? arr.filter((c) => f(c.class)) : arr;
883
+ }
884
+ function filterEntries(arr, pattern, growthOnly) {
885
+ const f = makeFilter(pattern);
886
+ return arr.filter((e) => {
887
+ if (growthOnly && e.delta <= 0)
888
+ return false;
889
+ if (f && !f(e.key))
890
+ return false;
891
+ return true;
892
+ });
893
+ }
894
+ function filterClassDiffs(arr, pattern, growthOnly) {
895
+ const f = makeFilter(pattern);
896
+ return arr.filter((c) => {
897
+ if (growthOnly && c.deltaBytes <= 0)
898
+ return false;
899
+ if (f && !f(c.class))
900
+ return false;
901
+ return true;
902
+ });
903
+ }
904
+ function formatReport(r, opts = {}) {
905
+ const top = opts.top ?? 20;
385
906
  const lines = [];
386
907
  lines.push(`Device: ${r.deviceId} (${r.platform})`);
387
908
  if (r.appId)
388
909
  lines.push(`App: ${r.appId}${r.pid ? ` (pid ${r.pid})` : ''}`);
910
+ if (r.capturedAt)
911
+ lines.push(`Captured: ${r.capturedAt}`);
389
912
  if (r.system) {
390
913
  lines.push('');
391
914
  lines.push('System memory:');
@@ -407,6 +930,10 @@ function formatReport(r) {
407
930
  lines.push('');
408
931
  lines.push('App memory:');
409
932
  const a = r.app;
933
+ if (a.footprintBytes !== undefined)
934
+ lines.push(` Footprint: ${fmtBytes(a.footprintBytes)} (jetsam target on iOS)`);
935
+ if (a.dirtyBytes !== undefined)
936
+ lines.push(` Dirty: ${fmtBytes(a.dirtyBytes)}`);
410
937
  if (a.totalPssBytes !== undefined)
411
938
  lines.push(` PSS total: ${fmtBytes(a.totalPssBytes)}`);
412
939
  if (a.totalRssBytes !== undefined)
@@ -431,24 +958,46 @@ function formatReport(r) {
431
958
  lines.push(` System: ${fmtBytes(a.systemBytes)}`);
432
959
  if (a.regions && Object.keys(a.regions).length > 0) {
433
960
  lines.push('');
434
- lines.push('Top memory regions (resident):');
961
+ lines.push(`Top memory regions (resident, top ${top}):`);
435
962
  const entries = Object.entries(a.regions)
436
963
  .filter(([, v]) => v > 0)
437
- .sort((a, b) => b[1] - a[1])
438
- .slice(0, 12);
964
+ .sort((x, y) => y[1] - x[1])
965
+ .slice(0, top);
439
966
  for (const [name, bytes] of entries) {
440
- lines.push(` ${name.padEnd(28)} ${fmtBytes(bytes)}`);
967
+ lines.push(` ${name.padEnd(30)} ${fmtBytes(bytes)}`);
441
968
  }
442
969
  }
443
970
  }
971
+ if (r.objectClasses && r.objectClasses.length > 0) {
972
+ const filtered = filterClasses(r.objectClasses, opts.filter);
973
+ lines.push('');
974
+ if (r.heapTotals) {
975
+ lines.push(`Heap objects (${r.heapTotals.count.toLocaleString()} nodes, ${fmtBytes(r.heapTotals.bytes)} total) — top ${top} by bytes${opts.filter ? ` (filter: /${opts.filter}/)` : ''}:`);
976
+ }
977
+ else {
978
+ lines.push(`Heap objects (top ${top} by bytes${opts.filter ? ` (filter: /${opts.filter}/)` : ''}):`);
979
+ }
980
+ lines.push(` ${'COUNT'.padStart(8)} ${'BYTES'.padStart(10)} CLASS`);
981
+ for (const c of filtered.slice(0, top)) {
982
+ const name = c.binary ? `${c.class} [${c.binary}]` : c.class;
983
+ lines.push(` ${String(c.count).padStart(8)} ${fmtBytes(c.bytes).padStart(10)} ${name}`);
984
+ }
985
+ }
444
986
  if (r.objects && Object.keys(r.objects).length > 0) {
445
987
  lines.push('');
446
988
  lines.push('Object counts:');
447
- const entries = Object.entries(r.objects).sort((a, b) => b[1] - a[1]);
989
+ const entries = Object.entries(r.objects).sort((x, y) => y[1] - x[1]);
448
990
  for (const [name, count] of entries) {
449
991
  lines.push(` ${name.padEnd(20)} ${count}`);
450
992
  }
451
993
  }
994
+ if (r.leaks) {
995
+ lines.push('');
996
+ lines.push(`Leaks: ${r.leaks.totalCount} leak${r.leaks.totalCount === 1 ? '' : 's'} (${fmtBytes(r.leaks.totalBytes)})`);
997
+ for (const l of r.leaks.classes.slice(0, top)) {
998
+ lines.push(` ${String(l.count).padStart(6)} ${fmtBytes(l.bytes).padStart(10)} ${l.class}`);
999
+ }
1000
+ }
452
1001
  if (r.notes && r.notes.length > 0) {
453
1002
  lines.push('');
454
1003
  for (const n of r.notes)
@@ -456,8 +1005,94 @@ function formatReport(r) {
456
1005
  }
457
1006
  return lines.join('\n');
458
1007
  }
1008
+ function formatDiff(d, opts = {}) {
1009
+ const top = opts.top ?? 20;
1010
+ const lines = [];
1011
+ const tag = [];
1012
+ if (opts.growthOnly)
1013
+ tag.push('growth-only');
1014
+ if (opts.filter)
1015
+ tag.push(`filter: /${opts.filter}/`);
1016
+ const tagStr = tag.length > 0 ? ` [${tag.join(', ')}]` : '';
1017
+ lines.push(`Diff: ${d.before.name} → ${d.after.name}${tagStr}`);
1018
+ if (d.before.capturedAt || d.after.capturedAt) {
1019
+ lines.push(` ${d.before.capturedAt ?? '?'} → ${d.after.capturedAt ?? '?'}`);
1020
+ }
1021
+ // App memory deltas — only filtered by growth-only (key is e.g. "totalRssBytes",
1022
+ // not user-meaningful for regex filtering).
1023
+ const appRows = opts.growthOnly ? d.app.filter((e) => e.delta > 0) : d.app;
1024
+ if (appRows.length > 0) {
1025
+ lines.push('');
1026
+ lines.push('App memory deltas:');
1027
+ for (const e of appRows.slice(0, top)) {
1028
+ lines.push(` ${e.key.padEnd(20)} ${fmtBytes(e.before).padStart(10)} → ${fmtBytes(e.after).padStart(10)} (${fmtSignedBytes(e.delta)})`);
1029
+ }
1030
+ }
1031
+ const regionRows = filterEntries(d.regions, opts.filter, opts.growthOnly);
1032
+ if (regionRows.length > 0) {
1033
+ lines.push('');
1034
+ lines.push(`Region deltas (top ${top} by |Δ|):`);
1035
+ for (const e of regionRows.slice(0, top)) {
1036
+ lines.push(` ${e.key.padEnd(30)} ${fmtBytes(e.before).padStart(10)} → ${fmtBytes(e.after).padStart(10)} (${fmtSignedBytes(e.delta)})`);
1037
+ }
1038
+ }
1039
+ const classRows = filterClassDiffs(d.objectClasses, opts.filter, opts.growthOnly);
1040
+ if (classRows.length > 0) {
1041
+ lines.push('');
1042
+ lines.push(`Class deltas (top ${top} by |Δ bytes|):`);
1043
+ lines.push(` ${'Δ COUNT'.padStart(8)} ${'Δ BYTES'.padStart(10)} ${'AFTER COUNT'.padStart(11)} ${'AFTER BYTES'.padStart(11)} CLASS`);
1044
+ for (const c of classRows.slice(0, top)) {
1045
+ lines.push(` ${fmtSignedInt(c.deltaCount).padStart(8)} ${fmtSignedBytes(c.deltaBytes).padStart(10)} ${String(c.afterCount).padStart(11)} ${fmtBytes(c.afterBytes).padStart(11)} ${c.class}`);
1046
+ }
1047
+ }
1048
+ const objRows = filterEntries(d.objects, opts.filter, opts.growthOnly);
1049
+ if (objRows.length > 0) {
1050
+ lines.push('');
1051
+ lines.push('Object count deltas:');
1052
+ for (const e of objRows.slice(0, top)) {
1053
+ lines.push(` ${e.key.padEnd(20)} ${String(e.before).padStart(8)} → ${String(e.after).padStart(8)} (${fmtSignedInt(e.delta)})`);
1054
+ }
1055
+ }
1056
+ if (d.leaksDelta && (d.leaksDelta.count !== 0 || d.leaksDelta.bytes !== 0)) {
1057
+ if (!opts.growthOnly || d.leaksDelta.bytes > 0) {
1058
+ lines.push('');
1059
+ lines.push(`Leaks delta: ${fmtSignedInt(d.leaksDelta.count)} leak(s), ${fmtSignedBytes(d.leaksDelta.bytes)}`);
1060
+ }
1061
+ }
1062
+ if (lines.length <= 2)
1063
+ lines.push('', 'No differences detected.');
1064
+ return lines.join('\n');
1065
+ }
459
1066
  // ── Entry point ───────────────────────────────────────────────────────────────
460
- async function memory(appIdArg, opts = {}, sessionName = 'default', _memOpts = {}) {
1067
+ async function memory(appIdArg, opts = {}, sessionName = 'default', memOpts = {}) {
1068
+ // List snapshots — no device required.
1069
+ if (memOpts.listSnapshots) {
1070
+ const list = await listSnapshots();
1071
+ if (opts.json) {
1072
+ (0, output_js_1.printData)({ status: 'ok', snapshots: list }, opts);
1073
+ }
1074
+ else if (list.length === 0) {
1075
+ console.log(`No snapshots saved. Try: conductor memory --save baseline`);
1076
+ }
1077
+ else {
1078
+ console.log(`Snapshots in ${snapshotsDir()}:`);
1079
+ for (const s of list) {
1080
+ console.log(` ${s.name.padEnd(24)} ${(s.platform ?? '?').padEnd(8)} ${s.appId ?? ''} ${s.capturedAt ?? ''}`);
1081
+ }
1082
+ }
1083
+ return 0;
1084
+ }
1085
+ // Diff between two saved snapshots.
1086
+ if (memOpts.diff && memOpts.diffOther) {
1087
+ const a = await loadSnapshot(memOpts.diff);
1088
+ const b = await loadSnapshot(memOpts.diffOther);
1089
+ const d = buildDiff(a, b, memOpts.diff, memOpts.diffOther);
1090
+ if (opts.json)
1091
+ (0, output_js_1.printData)({ status: 'ok', diff: d }, opts);
1092
+ else
1093
+ console.log(formatDiff(d, memOpts));
1094
+ return 0;
1095
+ }
461
1096
  const deviceId = await resolveDeviceId(sessionName);
462
1097
  if (!deviceId) {
463
1098
  (0, output_js_1.printError)('No device found. Connect a device or start a simulator first.', opts);
@@ -466,22 +1101,40 @@ async function memory(appIdArg, opts = {}, sessionName = 'default', _memOpts = {
466
1101
  const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
467
1102
  let report;
468
1103
  if (platform === 'web') {
469
- report = await collectWeb(deviceId, sessionName);
1104
+ report = await collectWeb(deviceId, sessionName, memOpts);
470
1105
  }
471
1106
  else {
472
- const appId = await resolveAppId(appIdArg, sessionName);
1107
+ const appId = await resolveAppId(appIdArg, sessionName, deviceId);
473
1108
  if (platform === 'android') {
474
- report = await collectAndroid(deviceId, appId);
1109
+ report = await collectAndroid(deviceId, appId, memOpts);
475
1110
  }
476
1111
  else {
477
- report = await collectIOS(deviceId, platform, appId);
1112
+ report = await collectIOS(deviceId, platform, appId, memOpts);
478
1113
  }
479
1114
  }
1115
+ report.capturedAt = new Date().toISOString();
1116
+ // Diff current report against a saved snapshot.
1117
+ if (memOpts.diff) {
1118
+ const a = await loadSnapshot(memOpts.diff);
1119
+ const d = buildDiff(a, report, memOpts.diff, 'current');
1120
+ if (memOpts.save)
1121
+ await saveSnapshot(memOpts.save, report);
1122
+ if (opts.json)
1123
+ (0, output_js_1.printData)({ status: 'ok', diff: d, current: report }, opts);
1124
+ else
1125
+ console.log(formatDiff(d, memOpts));
1126
+ return 0;
1127
+ }
1128
+ if (memOpts.save) {
1129
+ const file = await saveSnapshot(memOpts.save, report);
1130
+ if (!opts.json)
1131
+ console.error(`Saved snapshot → ${file}`);
1132
+ }
480
1133
  if (opts.json) {
481
1134
  (0, output_js_1.printData)({ status: 'ok', ...report }, opts);
482
1135
  }
483
1136
  else {
484
- console.log(formatReport(report));
1137
+ console.log(formatReport(report, memOpts));
485
1138
  }
486
1139
  return 0;
487
1140
  }