@saptools/cf-inspector 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ Built so an AI agent (or a CI job) can drive a debugger from a single shell comm
22
22
  - ✅ **Conditional breakpoints** — `--condition 'req.userId === "abc"'` only pauses when the predicate is truthy
23
23
  - 🎭 **Multi-breakpoint** — repeat `--bp` to race several locations; first hit wins
24
24
  - 📡 **Non-pausing logpoints** — `cf-inspector log --at file:line --expr 'JSON.stringify({…})'` streams JSON Lines as the line executes, **without ever pausing the inspectee** (safe for production traffic)
25
- - 🧠 **Agent-friendly** — JSON-by-default I/O, deterministic shape, sensitive-name redaction (`password`, `token`, `secret`, `cookie`, …) baked in
25
+ - 🧠 **Agent-friendly** — JSON-by-default I/O, deterministic shape, sensitive-name redaction (`password`, `credentials`, `token`, `secret`, `cookie`, …) baked in
26
26
  - 🧭 **Path mapping** — local `src/handler.ts:42` is matched against the remote URL via a `urlRegex`, with optional `--remote-root` literal or regex (same DSL as `cds-debug`)
27
27
  - 🔁 **Composes with `cf-debugger`** — pass `--app/--region/--org/--space` and the tunnel is opened automatically; pass `--port` to attach to anything CDP-speaking
28
28
  - 🪶 **Tiny dependency footprint** — `commander` + `ws` only, no heavy CDP framework
@@ -109,15 +109,26 @@ cf-inspector snapshot --port 9229 \
109
109
  | `--port <number>` | Local port the inspector or tunnel listens on. **Required** unless `--app/--region/--org/--space` are all set |
110
110
  | `--bp <file:line>` | **Required.** Source location to break at. Pass multiple times to race several locations — the first one to hit wins |
111
111
  | `--condition <expr>` | Only pause when this JS expression evaluates truthy in the paused frame. Errors in the condition are silently treated as `false` by V8 |
112
- | `--capture <expr,…>` | Comma-separated expressions to evaluate in the paused frame; results merged into the snapshot |
112
+ | `--capture <expr,…>` | Top-level comma-separated expressions to evaluate in the paused frame; nested commas inside objects, arrays, calls, or strings are preserved |
113
113
  | `--timeout <seconds>` | How long to wait for the breakpoint to hit (default: `30`) |
114
114
  | `--remote-root <value>` | Optional path-mapping anchor: literal path or `regex:<pattern>` / `/pattern/flags` |
115
115
  | `--no-json` | Print a human-readable summary instead of JSON |
116
- | `--keep-paused` | Skip the auto-resume after capture (useful for diagnostics) |
117
-
118
- JSON output includes `captureDurationMs`, the time spent collecting scopes and
119
- `--capture` expressions after the breakpoint has paused the process. It does
120
- not include the time spent waiting for the breakpoint to hit.
116
+ | `--keep-paused` | Skip `Debugger.resume` after capture; Node may resume when the CLI disconnects |
117
+ | `--fail-on-unmatched-pause` | Fail immediately if the target pauses somewhere else instead of waiting cooperatively |
118
+
119
+ JSON output includes `pausedDurationMs`, the client-observed time from receiving
120
+ the matching pause event until `Debugger.resume` completes. It does not include
121
+ the time spent waiting for the breakpoint to hit. When `--keep-paused` is used,
122
+ `pausedDurationMs` is `null` because `cf-inspector` intentionally skips
123
+ `Debugger.resume`. Node may resume execution when this one-shot CLI disconnects,
124
+ so treat `--keep-paused` as a low-level diagnostic escape hatch, not a durable
125
+ paused-session mode.
126
+
127
+ If the target pauses somewhere else first, for example another debugger's
128
+ breakpoint or a `debugger;` statement, `snapshot` does not resume it by default.
129
+ It warns once, waits for `Debugger.resumed`, then continues waiting for its own
130
+ breakpoint within the remaining timeout. Use `--fail-on-unmatched-pause` when a
131
+ strict immediate error is preferred.
121
132
 
122
133
  For Cloud Foundry targets, replace `--port` with `--region/--org/--space/--app` (and optionally `--cf-timeout <seconds>` for the tunnel).
123
134
 
@@ -160,7 +171,7 @@ When the user expression throws, the event is emitted with `error` instead of `v
160
171
 
161
172
  ### 🧮 `cf-inspector eval`
162
173
 
163
- Evaluate one expression and print the result. If a breakpoint is currently paused, it runs in the top frame; otherwise it runs against `Runtime.evaluate` in the global scope.
174
+ Evaluate one expression with `Runtime.evaluate` in the global scope and print the result. For paused-frame values, use `snapshot --capture` or the programmatic `evaluateOnFrame(...)` API.
164
175
 
165
176
  ```bash
166
177
  cf-inspector eval --port 9229 --expr 'process.uptime()'
@@ -203,7 +214,11 @@ const bp = await setBreakpoint(session, {
203
214
  });
204
215
  const pause = await waitForPause(session, { timeoutMs: 30_000 });
205
216
  const snapshot = await captureSnapshot(session, pause);
206
- const customValue = await evaluateOnFrame(session, pause.callFrames[0]!.callFrameId, "this.user");
217
+ const topFrame = pause.callFrames[0];
218
+ if (topFrame === undefined) {
219
+ throw new Error("Breakpoint paused without a call frame");
220
+ }
221
+ const customValue = await evaluateOnFrame(session, topFrame.callFrameId, "this.user");
207
222
  await resume(session);
208
223
  await session.dispose();
209
224
 
@@ -246,6 +261,8 @@ console.log({ bp, snapshot, customValue });
246
261
  | `INSPECTOR_CONNECTION_FAILED` | WebSocket handshake failed, or the connection closed mid-request |
247
262
  | `CDP_REQUEST_FAILED` | A CDP method returned an error result, timed out, or failed to send |
248
263
  | `BREAKPOINT_NOT_HIT` | The breakpoint did not hit before the timeout elapsed |
264
+ | `UNRELATED_PAUSE` | The target paused somewhere else and `--fail-on-unmatched-pause` was enabled |
265
+ | `UNRELATED_PAUSE_TIMEOUT` | The target stayed paused somewhere else until the snapshot timeout elapsed |
249
266
  | `EVALUATION_FAILED` | Reserved for future use — current evaluation paths surface remote exceptions inline via `CapturedExpression.error` instead of throwing |
250
267
  | `MISSING_TARGET` | Neither `--port` nor a complete CF target (`--region/--org/--space/--app`) was provided |
251
268
  | `ABORTED` | Reserved for future use by long-running streams when an `AbortSignal` fires |
@@ -271,10 +288,10 @@ Path mapping uses CDP's first-class `urlRegex`:
271
288
 
272
289
  | `--remote-root` | Resulting urlRegex (line `42` of `src/handler.ts`) |
273
290
  | --- | --- |
274
- | _omitted_ | `(?:^|/)src/handler\.(?:ts\|js)$` |
275
- | `/home/vcap/app` (literal) | `^file:///home/vcap/app/src/handler\.(?:ts\|js)$` |
276
- | `regex:^/example-root-.*$` | `^file:///example-root-[^/]+/src/handler\.(?:ts\|js)$` |
277
- | `/^/example-root-.*$/` | same as above |
291
+ | _omitted_ | `(?:^|/)src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$` |
292
+ | `/home/vcap/app` (literal) | `^file:///home/vcap/app/src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$` |
293
+ | `regex:^/example-root-.*$` | `^file:///example-root-.*/src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$` |
294
+ | `regex:^/(home/vcap/app\|example-root-.*)$` | `^file:///(home/vcap/app\|example-root-.*)/src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$` |
278
295
 
279
296
  `.ts ↔ .js` is folded into the regex automatically because Node's V8 inspector normally serves both the source-mapped TypeScript URL and the runtime JavaScript URL — matching either is correct.
280
297
 
package/dist/cli.js CHANGED
@@ -118,11 +118,13 @@ var init_wsTransport = __esm({
118
118
  });
119
119
 
120
120
  // src/cli.ts
121
+ import { performance as performance2 } from "perf_hooks";
121
122
  import process from "process";
122
123
  import { Command } from "commander";
123
124
 
124
125
  // src/inspector.ts
125
126
  import { request } from "http";
127
+ import { performance } from "perf_hooks";
126
128
 
127
129
  // src/cdp.ts
128
130
  init_types();
@@ -429,6 +431,15 @@ function stripTrailingSlash(value) {
429
431
  }
430
432
  return value;
431
433
  }
434
+ function normalizeRegexRootPattern(pattern) {
435
+ const withoutStartAnchor = pattern.startsWith("^") ? pattern.slice(1) : pattern;
436
+ const withoutEndAnchor = withoutStartAnchor.endsWith("$") && !isEscaped(withoutStartAnchor, withoutStartAnchor.length - 1) ? withoutStartAnchor.slice(0, -1) : withoutStartAnchor;
437
+ return stripTrailingSlash(withoutEndAnchor);
438
+ }
439
+ function buildFileUrlRegex(rootPattern, tail) {
440
+ const separator = rootPattern.endsWith("/") ? "" : "/";
441
+ return `^file://${rootPattern}${separator}${tail}$`;
442
+ }
432
443
  function escapeRegExp(value) {
433
444
  return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
434
445
  }
@@ -455,10 +466,11 @@ function buildBreakpointUrlRegex(input) {
455
466
  }
456
467
  case "literal": {
457
468
  const escapedRoot = escapeRegExp(input.remoteRoot.value);
458
- return `^file://${escapedRoot}/${tail}$`;
469
+ return buildFileUrlRegex(escapedRoot, tail);
459
470
  }
460
471
  case "regex": {
461
- return `^file://${input.remoteRoot.pattern}/${tail}$`;
472
+ const rootPattern = normalizeRegexRootPattern(input.remoteRoot.pattern);
473
+ return buildFileUrlRegex(rootPattern, tail);
462
474
  }
463
475
  }
464
476
  }
@@ -607,21 +619,21 @@ async function connectInspector(options) {
607
619
  const PAUSE_BUFFER_LIMIT = 32;
608
620
  const pauseBuffer = [];
609
621
  const pauseWaitGate = { active: false };
622
+ const debuggerState = {};
610
623
  client.on("Debugger.paused", (raw) => {
611
624
  if (pauseWaitGate.active) {
612
625
  return;
613
626
  }
614
627
  const params = raw;
615
- const event = {
616
- reason: asString(params.reason),
617
- hitBreakpoints: Array.isArray(params.hitBreakpoints) ? params.hitBreakpoints.filter((id) => typeof id === "string") : [],
618
- callFrames: toCallFrames(params.callFrames)
619
- };
628
+ const event = toPauseEvent(params, performance.now());
620
629
  if (pauseBuffer.length >= PAUSE_BUFFER_LIMIT) {
621
630
  pauseBuffer.shift();
622
631
  }
623
632
  pauseBuffer.push(event);
624
633
  });
634
+ client.on("Debugger.resumed", () => {
635
+ debuggerState.lastResumedAtMs = performance.now();
636
+ });
625
637
  await client.send("Runtime.enable");
626
638
  await client.send("Debugger.enable");
627
639
  return {
@@ -630,6 +642,7 @@ async function connectInspector(options) {
630
642
  scripts,
631
643
  pauseBuffer,
632
644
  pauseWaitGate,
645
+ debuggerState,
633
646
  dispose: async () => {
634
647
  try {
635
648
  await client.send("Debugger.disable");
@@ -747,36 +760,121 @@ function pauseMatches(pause, breakpointIds) {
747
760
  }
748
761
  return pause.hitBreakpoints.some((id) => breakpointIds.includes(id));
749
762
  }
763
+ function toPauseEvent(params, receivedAtMs) {
764
+ return {
765
+ reason: asString(params.reason),
766
+ hitBreakpoints: Array.isArray(params.hitBreakpoints) ? params.hitBreakpoints.filter((id) => typeof id === "string") : [],
767
+ callFrames: toCallFrames(params.callFrames),
768
+ receivedAtMs
769
+ };
770
+ }
771
+ function remainingUntil(deadlineMs) {
772
+ return Math.max(0, deadlineMs - performance.now());
773
+ }
774
+ function topFrameLocation(pause) {
775
+ const top = pause.callFrames[0];
776
+ if (top === void 0) {
777
+ return "(no call frame)";
778
+ }
779
+ const url = top.url !== void 0 && top.url.length > 0 ? top.url : "(unknown)";
780
+ return `${url}:${(top.lineNumber + 1).toString()}:${(top.columnNumber + 1).toString()}`;
781
+ }
782
+ function pauseDetail(pause) {
783
+ return JSON.stringify({
784
+ reason: pause.reason,
785
+ hitBreakpoints: pause.hitBreakpoints,
786
+ topFrame: topFrameLocation(pause)
787
+ });
788
+ }
789
+ function hasResumedSincePause(session, pause) {
790
+ const pauseAt = pause.receivedAtMs;
791
+ const resumedAt = session.debuggerState.lastResumedAtMs;
792
+ return pauseAt !== void 0 && resumedAt !== void 0 && resumedAt >= pauseAt;
793
+ }
794
+ function throwBreakpointTimeout(timeoutMs) {
795
+ throw new CfInspectorError(
796
+ "BREAKPOINT_NOT_HIT",
797
+ `Timed out waiting for matching Debugger.paused after ${timeoutMs.toString()}ms`
798
+ );
799
+ }
800
+ function throwUnrelatedPauseTimeout(pause, timeoutMs) {
801
+ throw new CfInspectorError(
802
+ "UNRELATED_PAUSE_TIMEOUT",
803
+ `Target stayed paused by another debugger event before this command's breakpoint could hit within ${timeoutMs.toString()}ms`,
804
+ pauseDetail(pause)
805
+ );
806
+ }
807
+ async function waitForUnmatchedPauseToResume(session, pause, deadlineMs, timeoutMs) {
808
+ if (hasResumedSincePause(session, pause)) {
809
+ return;
810
+ }
811
+ const remainingMs = remainingUntil(deadlineMs);
812
+ if (remainingMs <= 0) {
813
+ throwUnrelatedPauseTimeout(pause, timeoutMs);
814
+ }
815
+ try {
816
+ await session.client.waitFor("Debugger.resumed", { timeoutMs: remainingMs });
817
+ session.debuggerState.lastResumedAtMs = performance.now();
818
+ } catch (err) {
819
+ if (err instanceof CfInspectorError && err.code === "BREAKPOINT_NOT_HIT") {
820
+ throwUnrelatedPauseTimeout(pause, timeoutMs);
821
+ }
822
+ throw err;
823
+ }
824
+ }
825
+ async function handleUnmatchedPause(session, pause, options, deadlineMs) {
826
+ if (options.unmatchedPausePolicy === "fail") {
827
+ throw new CfInspectorError(
828
+ "UNRELATED_PAUSE",
829
+ "Target paused before this command's breakpoint was reached",
830
+ pauseDetail(pause)
831
+ );
832
+ }
833
+ if (hasResumedSincePause(session, pause)) {
834
+ return;
835
+ }
836
+ options.onUnmatchedPause?.(pause);
837
+ await waitForUnmatchedPauseToResume(session, pause, deadlineMs, options.timeoutMs);
838
+ }
750
839
  async function waitForPause(session, options) {
840
+ const deadlineMs = performance.now() + options.timeoutMs;
751
841
  const buffer = session.pauseBuffer;
752
- while (buffer.length > 0) {
753
- const head = buffer.shift();
754
- if (head !== void 0 && pauseMatches(head, options.breakpointIds)) {
755
- return head;
842
+ while (buffer.length > 0 || remainingUntil(deadlineMs) > 0) {
843
+ while (buffer.length > 0) {
844
+ const buffered = buffer.shift();
845
+ if (buffered === void 0) {
846
+ continue;
847
+ }
848
+ if (pauseMatches(buffered, options.breakpointIds)) {
849
+ return buffered;
850
+ }
851
+ await handleUnmatchedPause(session, buffered, options, deadlineMs);
756
852
  }
757
- }
758
- session.pauseWaitGate.active = true;
759
- let params;
760
- try {
761
- const expected = options.breakpointIds;
762
- params = await session.client.waitFor("Debugger.paused", {
763
- timeoutMs: options.timeoutMs,
764
- predicate: (raw) => {
765
- if (expected === void 0 || expected.length === 0) {
853
+ const remainingMs = remainingUntil(deadlineMs);
854
+ if (remainingMs <= 0) {
855
+ throwBreakpointTimeout(options.timeoutMs);
856
+ }
857
+ session.pauseWaitGate.active = true;
858
+ let receivedAtMs;
859
+ let params;
860
+ try {
861
+ params = await session.client.waitFor("Debugger.paused", {
862
+ timeoutMs: remainingMs,
863
+ predicate: () => {
864
+ receivedAtMs = performance.now();
766
865
  return true;
767
866
  }
768
- const ids = Array.isArray(raw.hitBreakpoints) ? raw.hitBreakpoints.filter((id) => typeof id === "string") : [];
769
- return ids.some((id) => expected.includes(id));
770
- }
771
- });
772
- } finally {
773
- session.pauseWaitGate.active = false;
867
+ });
868
+ } finally {
869
+ session.pauseWaitGate.active = false;
870
+ }
871
+ const pause = toPauseEvent(params, receivedAtMs ?? performance.now());
872
+ if (pauseMatches(pause, options.breakpointIds)) {
873
+ return pause;
874
+ }
875
+ await handleUnmatchedPause(session, pause, options, deadlineMs);
774
876
  }
775
- return {
776
- reason: asString(params.reason),
777
- hitBreakpoints: Array.isArray(params.hitBreakpoints) ? params.hitBreakpoints.filter((id) => typeof id === "string") : [],
778
- callFrames: toCallFrames(params.callFrames)
779
- };
877
+ throwBreakpointTimeout(options.timeoutMs);
780
878
  }
781
879
  async function resume(session) {
782
880
  await session.client.send("Debugger.resume");
@@ -969,13 +1067,12 @@ async function waitForStop(session, options) {
969
1067
  }
970
1068
 
971
1069
  // src/snapshot.ts
972
- import { performance } from "perf_hooks";
973
1070
  var MAX_SCOPES = 3;
974
1071
  var MAX_SCOPE_VARIABLES = 20;
975
1072
  var MAX_CHILD_VARIABLES = 8;
976
1073
  var MAX_VARIABLE_DEPTH = 2;
977
1074
  var MAX_VALUE_LENGTH = 240;
978
- var SENSITIVE_NAME_REGEX = /(pass(?:word)?|token|secret|api[_-]?key|authorization|cookie|session|private[_-]?key)/i;
1075
+ var SENSITIVE_NAME_REGEX = /(pass(?:word)?|credentials?|creds?|token|secret|api[_-]?key|authorization|cookie|session|private[_-]?key)/i;
979
1076
  var PRIORITY_BY_TYPE = {
980
1077
  local: 0,
981
1078
  arguments: 1,
@@ -1036,8 +1133,11 @@ function formatPrimitive(value) {
1036
1133
  }
1037
1134
  return String(value);
1038
1135
  }
1136
+ function isSensitiveName(name) {
1137
+ return SENSITIVE_NAME_REGEX.test(name);
1138
+ }
1039
1139
  function sanitizeValue(name, raw) {
1040
- if (SENSITIVE_NAME_REGEX.test(name)) {
1140
+ if (isSensitiveName(name)) {
1041
1141
  return "[REDACTED]";
1042
1142
  }
1043
1143
  if (raw.length <= MAX_VALUE_LENGTH) {
@@ -1055,8 +1155,9 @@ async function captureProperties(session, objectId, limit, depth) {
1055
1155
  limited.map(async (prop) => {
1056
1156
  const name = typeof prop.name === "string" ? prop.name : "?";
1057
1157
  const described = describeProperty(prop);
1158
+ const sensitive = isSensitiveName(name);
1058
1159
  let children;
1059
- if (depth > 0 && described.objectId !== void 0 && isExpandable(described.type)) {
1160
+ if (!sensitive && depth > 0 && described.objectId !== void 0 && isExpandable(described.type)) {
1060
1161
  try {
1061
1162
  const nested = await captureProperties(
1062
1163
  session,
@@ -1070,7 +1171,7 @@ async function captureProperties(session, objectId, limit, depth) {
1070
1171
  } catch {
1071
1172
  }
1072
1173
  }
1073
- const sanitizedValue = sanitizeValue(name, described.value);
1174
+ const sanitizedValue = sensitive ? "[REDACTED]" : sanitizeValue(name, described.value);
1074
1175
  const base = { name, value: sanitizedValue };
1075
1176
  const withType = described.type === void 0 ? base : { ...base, type: described.type };
1076
1177
  return children === void 0 ? withType : { ...withType, children };
@@ -1128,7 +1229,6 @@ function evalResultToCaptured(expression, result) {
1128
1229
  return buildCaptured("undefined");
1129
1230
  }
1130
1231
  async function captureSnapshot(session, pause, options = {}) {
1131
- const startedAt = performance.now();
1132
1232
  const top = pause.callFrames[0];
1133
1233
  let topFrame;
1134
1234
  let captures = [];
@@ -1155,12 +1255,10 @@ async function captureSnapshot(session, pause, options = {}) {
1155
1255
  );
1156
1256
  }
1157
1257
  }
1158
- const captureDurationMs = Math.round((performance.now() - startedAt) * 1e3) / 1e3;
1159
1258
  return {
1160
1259
  reason: pause.reason,
1161
1260
  hitBreakpoints: pause.hitBreakpoints,
1162
1261
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
1163
- captureDurationMs,
1164
1262
  ...topFrame === void 0 ? {} : { topFrame },
1165
1263
  captures
1166
1264
  };
@@ -1251,7 +1349,82 @@ function parseCaptureList(raw) {
1251
1349
  if (raw === void 0 || raw.trim().length === 0) {
1252
1350
  return [];
1253
1351
  }
1254
- return raw.split(",").map((piece) => piece.trim()).filter((piece) => piece.length > 0);
1352
+ return splitCaptureExpressions(raw);
1353
+ }
1354
+ function isQuoteChar(value) {
1355
+ return value === "'" || value === '"' || value === "`";
1356
+ }
1357
+ function consumeQuotedChar(state, char) {
1358
+ if (state.quote === void 0) {
1359
+ return false;
1360
+ }
1361
+ if (state.escaped) {
1362
+ state.escaped = false;
1363
+ return true;
1364
+ }
1365
+ if (char === "\\") {
1366
+ state.escaped = true;
1367
+ return true;
1368
+ }
1369
+ if (char === state.quote) {
1370
+ state.quote = void 0;
1371
+ }
1372
+ return true;
1373
+ }
1374
+ function updateCaptureDepth(state, char) {
1375
+ if (char === "(") {
1376
+ state.parenDepth += 1;
1377
+ } else if (char === ")") {
1378
+ state.parenDepth = Math.max(0, state.parenDepth - 1);
1379
+ } else if (char === "[") {
1380
+ state.bracketDepth += 1;
1381
+ } else if (char === "]") {
1382
+ state.bracketDepth = Math.max(0, state.bracketDepth - 1);
1383
+ } else if (char === "{") {
1384
+ state.braceDepth += 1;
1385
+ } else if (char === "}") {
1386
+ state.braceDepth = Math.max(0, state.braceDepth - 1);
1387
+ }
1388
+ }
1389
+ function isTopLevel(state) {
1390
+ return state.parenDepth === 0 && state.bracketDepth === 0 && state.braceDepth === 0;
1391
+ }
1392
+ function appendCapturePiece(raw, state, end) {
1393
+ const piece = raw.slice(state.start, end).trim();
1394
+ if (piece.length > 0) {
1395
+ state.pieces.push(piece);
1396
+ }
1397
+ }
1398
+ function splitCaptureExpressions(raw) {
1399
+ const state = {
1400
+ escaped: false,
1401
+ parenDepth: 0,
1402
+ bracketDepth: 0,
1403
+ braceDepth: 0,
1404
+ quote: void 0,
1405
+ start: 0,
1406
+ pieces: []
1407
+ };
1408
+ for (let idx = 0; idx < raw.length; idx += 1) {
1409
+ const char = raw[idx];
1410
+ if (char === void 0) {
1411
+ continue;
1412
+ }
1413
+ if (consumeQuotedChar(state, char)) {
1414
+ continue;
1415
+ }
1416
+ if (isQuoteChar(char)) {
1417
+ state.quote = char;
1418
+ continue;
1419
+ }
1420
+ updateCaptureDepth(state, char);
1421
+ if (char === "," && isTopLevel(state)) {
1422
+ appendCapturePiece(raw, state, idx);
1423
+ state.start = idx + 1;
1424
+ }
1425
+ }
1426
+ appendCapturePiece(raw, state, raw.length);
1427
+ return state.pieces;
1255
1428
  }
1256
1429
  async function withSession(target, fn) {
1257
1430
  const tunnel = await openTarget(target);
@@ -1280,12 +1453,41 @@ function warnOnUnboundBreakpoints(handles) {
1280
1453
  }
1281
1454
  }
1282
1455
  }
1456
+ function roundDurationMs(durationMs) {
1457
+ return Math.round(durationMs * 1e3) / 1e3;
1458
+ }
1459
+ function formatPauseLocation(pause) {
1460
+ const top = pause.callFrames[0];
1461
+ if (top === void 0) {
1462
+ return "(no call frame)";
1463
+ }
1464
+ const url = top.url !== void 0 && top.url.length > 0 ? top.url : "(unknown)";
1465
+ return `${url}:${(top.lineNumber + 1).toString()}:${(top.columnNumber + 1).toString()}`;
1466
+ }
1467
+ function warnOnUnmatchedPause(pause) {
1468
+ const reason = pause.reason.length > 0 ? pause.reason : "unknown";
1469
+ process.stderr.write(
1470
+ `[cf-inspector] warning: target is paused by another debugger event (${reason} at ${formatPauseLocation(pause)}); waiting for it to resume...
1471
+ `
1472
+ );
1473
+ }
1474
+ function withPausedDuration(snapshot, pausedDurationMs) {
1475
+ return {
1476
+ reason: snapshot.reason,
1477
+ hitBreakpoints: snapshot.hitBreakpoints,
1478
+ capturedAt: snapshot.capturedAt,
1479
+ pausedDurationMs,
1480
+ ...snapshot.topFrame === void 0 ? {} : { topFrame: snapshot.topFrame },
1481
+ captures: snapshot.captures
1482
+ };
1483
+ }
1283
1484
  function writeHumanSnapshot(snapshot) {
1485
+ const pausedDuration = snapshot.pausedDurationMs === null ? "unknown" : `${snapshot.pausedDurationMs.toFixed(1)}ms`;
1284
1486
  const lines = [];
1285
1487
  lines.push(
1286
1488
  `Snapshot @ ${snapshot.capturedAt}`,
1287
1489
  ` reason: ${snapshot.reason}`,
1288
- ` capture: ${snapshot.captureDurationMs.toFixed(1)}ms`
1490
+ ` paused: ${pausedDuration}`
1289
1491
  );
1290
1492
  if (snapshot.topFrame) {
1291
1493
  const frame = snapshot.topFrame;
@@ -1341,15 +1543,36 @@ async function handleSnapshot(opts) {
1341
1543
  );
1342
1544
  warnOnUnboundBreakpoints(handles);
1343
1545
  const breakpointIds = handles.map((h) => h.breakpointId);
1344
- const pause = await waitForPause(session, { timeoutMs, breakpointIds });
1345
- const snapshot = await captureSnapshot(session, pause, { captures });
1346
- if (opts.keepPaused !== true) {
1347
- try {
1348
- await resume(session);
1349
- } catch {
1546
+ let warnedUnmatchedPause = false;
1547
+ const pause = await waitForPause(session, {
1548
+ timeoutMs,
1549
+ breakpointIds,
1550
+ unmatchedPausePolicy: opts.failOnUnmatchedPause === true ? "fail" : "wait-for-resume",
1551
+ onUnmatchedPause: (unmatchedPause) => {
1552
+ if (warnedUnmatchedPause || opts.failOnUnmatchedPause === true) {
1553
+ return;
1554
+ }
1555
+ warnedUnmatchedPause = true;
1556
+ warnOnUnmatchedPause(unmatchedPause);
1350
1557
  }
1558
+ });
1559
+ const pausedStartedAt = pause.receivedAtMs ?? performance2.now();
1560
+ const snapshot = await captureSnapshot(session, pause, { captures });
1561
+ if (opts.keepPaused === true) {
1562
+ return withPausedDuration(snapshot, null);
1563
+ }
1564
+ try {
1565
+ await resume(session);
1566
+ return withPausedDuration(
1567
+ snapshot,
1568
+ roundDurationMs(performance2.now() - pausedStartedAt)
1569
+ );
1570
+ } catch {
1571
+ process.stderr.write(
1572
+ "[cf-inspector] warning: Debugger.resume failed after snapshot; pausedDurationMs is unknown.\n"
1573
+ );
1574
+ return withPausedDuration(snapshot, null);
1351
1575
  }
1352
- return snapshot;
1353
1576
  });
1354
1577
  if (opts.json) {
1355
1578
  writeJson(result);
@@ -1364,6 +1587,9 @@ async function handleEval(opts) {
1364
1587
  });
1365
1588
  if (opts.json) {
1366
1589
  writeJson(result);
1590
+ if (result.exceptionDetails !== void 0) {
1591
+ process.exitCode = 1;
1592
+ }
1367
1593
  return;
1368
1594
  }
1369
1595
  if (result.exceptionDetails !== void 0) {
@@ -1501,10 +1727,10 @@ async function main(argv) {
1501
1727
  "Breakpoint location (repeatable; first hit wins), e.g. src/handler.ts:42",
1502
1728
  collectStrings,
1503
1729
  []
1504
- ).option("--capture <expr,\u2026>", "Comma-separated expressions to evaluate in the paused frame").option("--timeout <seconds>", "How long to wait for the breakpoint to hit (default: 30)").option("--remote-root <value>", "Path-mapping anchor: literal path or regex:<pattern> / /pattern/flags").option(
1730
+ ).option("--capture <expr,\u2026>", "Top-level comma-separated expressions to evaluate in the paused frame").option("--timeout <seconds>", "How long to wait for the breakpoint to hit (default: 30)").option("--remote-root <value>", "Path-mapping anchor: literal path or regex:<pattern> / /pattern/flags").option(
1505
1731
  "--condition <expr>",
1506
1732
  "Only pause when this JS expression evaluates truthy in the paused frame"
1507
- ).option("--no-json", "Print a human-readable summary instead of JSON").option("--keep-paused", "Skip the auto-resume after capture").action(async (opts) => {
1733
+ ).option("--no-json", "Print a human-readable summary instead of JSON").option("--keep-paused", "Skip Debugger.resume after capture; Node may resume when this CLI disconnects").option("--fail-on-unmatched-pause", "Fail immediately if the target pauses somewhere else").action(async (opts) => {
1508
1734
  await handleSnapshot(opts);
1509
1735
  });
1510
1736
  applyTargetOptions(