@ait-co/devtools 0.1.127 → 0.1.129

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.
Files changed (68) hide show
  1. package/dist/{attach-orchestrator-CpE0dMew.js → attach-orchestrator-Bdt5Zdo9.js} +3 -94
  2. package/dist/attach-orchestrator-Bdt5Zdo9.js.map +1 -0
  3. package/dist/{attach-orchestrator-CPMQWkGV.js → attach-orchestrator-DvrgUIx7.js} +4 -95
  4. package/dist/attach-orchestrator-DvrgUIx7.js.map +1 -0
  5. package/dist/{bundle-Cb3GFuw6.d.ts → bundle-BKqyhEK9.d.ts} +1 -1
  6. package/dist/{bundle-Cb3GFuw6.d.ts.map → bundle-BKqyhEK9.d.ts.map} +1 -1
  7. package/dist/{capture-B1zfGyTo.d.ts → capture-ltuV0gZa.d.ts} +1 -1
  8. package/dist/{capture-B1zfGyTo.d.ts.map → capture-ltuV0gZa.d.ts.map} +1 -1
  9. package/dist/{cdp-connection-Dxw_EY8s.d.ts → cdp-connection-D7AYQck2.d.ts} +1 -1
  10. package/dist/{cdp-connection-Dxw_EY8s.d.ts.map → cdp-connection-D7AYQck2.d.ts.map} +1 -1
  11. package/dist/{cell-D0dJSGHi.js → cell-DA_lcpDI.js} +2 -2
  12. package/dist/{cell-D0dJSGHi.js.map → cell-DA_lcpDI.js.map} +1 -1
  13. package/dist/{cell-CX92lDI0.js → cell-DLRv4iG6.js} +2 -2
  14. package/dist/{cell-CX92lDI0.js.map → cell-DLRv4iG6.js.map} +1 -1
  15. package/dist/{debug-server-Dctc9GAh.js → debug-server--Y0QZVJS.js} +57 -58
  16. package/dist/debug-server--Y0QZVJS.js.map +1 -0
  17. package/dist/debug-server-Bu3pCG9c.js +436 -0
  18. package/dist/debug-server-Bu3pCG9c.js.map +1 -0
  19. package/dist/debug-server-e1qPe0ZB.js +379 -0
  20. package/dist/{debug-server-BzzNkETg.js.map → debug-server-e1qPe0ZB.js.map} +1 -1
  21. package/dist/log-CUik_tHp.js +95 -0
  22. package/dist/log-CUik_tHp.js.map +1 -0
  23. package/dist/mcp/cli.js +1296 -1230
  24. package/dist/mcp/cli.js.map +1 -1
  25. package/dist/mcp/server.js +1 -1
  26. package/dist/panel/index.js +1 -1
  27. package/dist/{pool-DBLKNeRC.d.ts → pool-C0pc7T2p.d.ts} +4 -4
  28. package/dist/{pool-DBLKNeRC.d.ts.map → pool-C0pc7T2p.d.ts.map} +1 -1
  29. package/dist/{qr-http-server-Bo8LMOJQ.js → qr-http-server-HANhOFwI.js} +1 -1
  30. package/dist/{qr-http-server-Bo8LMOJQ.js.map → qr-http-server-HANhOFwI.js.map} +1 -1
  31. package/dist/{relay-factory-Dug7W6Ao.js → relay-factory-Ck3vBonr.js} +6 -6
  32. package/dist/{relay-factory-Dug7W6Ao.js.map → relay-factory-Ck3vBonr.js.map} +1 -1
  33. package/dist/{relay-secret-store-DKxs7zwq.js → relay-secret-store-DzxDsK6S.js} +1 -1
  34. package/dist/{relay-secret-store-DKxs7zwq.js.map → relay-secret-store-DzxDsK6S.js.map} +1 -1
  35. package/dist/{relay-url-store-xmUuTjXA.js → relay-url-store-D3rY-GJJ.js} +2 -2
  36. package/dist/{relay-url-store-xmUuTjXA.js.map → relay-url-store-D3rY-GJJ.js.map} +1 -1
  37. package/dist/{debug-server-BzzNkETg.js → relay-worker-Bt0QYKQH.js} +212 -346
  38. package/dist/relay-worker-Bt0QYKQH.js.map +1 -0
  39. package/dist/{relay-worker-TReZtzGl.d.ts → relay-worker-o0M2ndyJ.d.ts} +7 -7
  40. package/dist/relay-worker-o0M2ndyJ.d.ts.map +1 -0
  41. package/dist/{runtime-ozyPnRbU.d.ts → runtime-9xhN9pr8.d.ts} +1 -1
  42. package/dist/{runtime-ozyPnRbU.d.ts.map → runtime-9xhN9pr8.d.ts.map} +1 -1
  43. package/dist/test-runner/bin.js +721 -15
  44. package/dist/test-runner/bin.js.map +1 -1
  45. package/dist/test-runner/bundle.d.ts +1 -1
  46. package/dist/test-runner/capture.d.ts +1 -1
  47. package/dist/test-runner/config.d.ts +1 -1
  48. package/dist/test-runner/config.js +1 -1
  49. package/dist/test-runner/pool.d.ts +1 -1
  50. package/dist/test-runner/pool.js +1 -1
  51. package/dist/test-runner/relay-factory.d.ts +1 -1
  52. package/dist/test-runner/relay-factory.js +1 -1
  53. package/dist/test-runner/relay-worker.d.ts +1 -1
  54. package/dist/test-runner/relay-worker.js +1 -183
  55. package/dist/test-runner/report.d.ts +3 -3
  56. package/dist/test-runner/rpc.d.ts +2 -2
  57. package/dist/test-runner/rpc.js +12 -3
  58. package/dist/test-runner/rpc.js.map +1 -1
  59. package/dist/test-runner/runtime.d.ts +1 -1
  60. package/dist/test-runner/task-graph.d.ts +1 -1
  61. package/package.json +1 -1
  62. package/dist/attach-orchestrator-CPMQWkGV.js.map +0 -1
  63. package/dist/attach-orchestrator-CpE0dMew.js.map +0 -1
  64. package/dist/debug-server-1dhDA3aw.js +0 -980
  65. package/dist/debug-server-1dhDA3aw.js.map +0 -1
  66. package/dist/debug-server-Dctc9GAh.js.map +0 -1
  67. package/dist/relay-worker-TReZtzGl.d.ts.map +0 -1
  68. package/dist/test-runner/relay-worker.js.map +0 -1
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ import { r as generateTotp } from "../totp-DvOYkYim.js";
2
3
  import * as path$1 from "node:path";
3
4
  import path, { basename, isAbsolute, resolve } from "node:path";
4
5
  import { parseArgs } from "node:util";
5
6
  import * as fs from "node:fs/promises";
6
7
  import { glob, mkdir, writeFile } from "node:fs/promises";
8
+ import { EventEmitter } from "node:events";
9
+ import { WebSocket } from "ws";
7
10
  import { accessSync } from "node:fs";
8
11
  import { fileURLToPath } from "node:url";
9
12
  //#region src/test-runner/discover.ts
@@ -60,10 +63,10 @@ function createRelayConnectionFactory(opts) {
60
63
  let qrServer;
61
64
  return {
62
65
  async open() {
63
- const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-CpE0dMew.js");
64
- const { injectDebugIndicator, injectGlobals } = await import("../cell-CX92lDI0.js");
66
+ const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-Bdt5Zdo9.js");
67
+ const { injectDebugIndicator, injectGlobals } = await import("../cell-DLRv4iG6.js");
65
68
  const { loadRelaySecretReadOnly } = await import("../relay-secret-store-CFc9n0OA.js");
66
- const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-BzzNkETg.js");
69
+ const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-e1qPe0ZB.js");
67
70
  await loadRelaySecretReadOnly({ projectRoot });
68
71
  let resolveTunnelUp;
69
72
  const tunnelReady = new Promise((resolve) => {
@@ -157,6 +160,662 @@ function createRelayConnectionFactory(opts) {
157
160
  };
158
161
  }
159
162
  //#endregion
163
+ //#region src/shared/relay-auth-close.ts
164
+ /**
165
+ * Shared constants for the relay's named TOTP-auth rejection (issue #478).
166
+ *
167
+ * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
168
+ * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
169
+ * indistinguishable from a network failure on the browser side — the
170
+ * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
171
+ * could not tell "stale TOTP code" apart from "tunnel down" and stayed
172
+ * silent. The fix is accept-then-close: complete the handshake, then close
173
+ * with an application close code that NAMES the rejection.
174
+ *
175
+ * Three parties share this contract:
176
+ * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
177
+ * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
178
+ * surfaces the code to the launcher shell;
179
+ * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
180
+ * as an auth failure on its own `/client` dial (defensive — #439's fresh
181
+ * code mint means it should not normally hit this).
182
+ *
183
+ * This module is intentionally dependency-free (no Node, no DOM) so it is
184
+ * safe to import from both the browser in-app bundle and the MCP daemon
185
+ * bundle.
186
+ *
187
+ * SECRET-HANDLING: these are fixed enum values. The close reason / error body
188
+ * must never grow to carry a secret, a TOTP code, or a host.
189
+ */
190
+ /**
191
+ * WebSocket close code sent by the relay when TOTP auth is rejected.
192
+ *
193
+ * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
194
+ * HTTP 401 so it reads as "unauthorized" at a glance.
195
+ */
196
+ const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
197
+ /**
198
+ * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
199
+ * the `error` value of the relay's HTTP 401 JSON body. Enum string only —
200
+ * never interpolated with request data.
201
+ */
202
+ const RELAY_AUTH_REJECT_REASON = "totp-rejected";
203
+ //#endregion
204
+ //#region src/mcp/log.ts
205
+ /**
206
+ * Allowed field keys that may pass through to a log line.
207
+ * Unknown keys are dropped. Values are still redact-scanned.
208
+ */
209
+ const ALLOWED_KEYS = new Set([
210
+ "ts",
211
+ "level",
212
+ "event",
213
+ "msg",
214
+ "port",
215
+ "totpEnabled",
216
+ "env",
217
+ "tool",
218
+ "deploymentId",
219
+ "errorKind",
220
+ "reason",
221
+ "prevTargetId",
222
+ "mode",
223
+ "fileCount",
224
+ "passed",
225
+ "failed",
226
+ "skipped"
227
+ ]);
228
+ /**
229
+ * Patterns that match secret values.
230
+ * Match order matters — more-specific patterns first.
231
+ *
232
+ * #268 redact script covers: relay=wss://…, at=<TOTP>, _deploymentId=<uuid>.
233
+ * Here we extend to in-process value-level patterns used in server logs.
234
+ */
235
+ const SECRET_PATTERNS = [
236
+ /^\d{6}$/,
237
+ /^(aitcc_|AITCC_)/i,
238
+ /^[A-Za-z0-9_-]+=.{4,}/,
239
+ /^wss:\/\//,
240
+ /(?:^|[?&])at=[A-Z0-9]{6}/i
241
+ ];
242
+ /**
243
+ * Returns `true` when the string value matches any known-secret pattern.
244
+ * Only string values are tested — numbers/booleans are always safe.
245
+ */
246
+ function isSecretValue(value) {
247
+ return SECRET_PATTERNS.some((re) => re.test(value));
248
+ }
249
+ /**
250
+ * Redacts a single scalar value.
251
+ * - strings: return "***" if the value matches a secret pattern.
252
+ * - other: return as-is.
253
+ */
254
+ function redactValue(value) {
255
+ if (typeof value === "string" && isSecretValue(value)) return "***";
256
+ return value;
257
+ }
258
+ /**
259
+ * Builds a safe log payload from raw fields.
260
+ *
261
+ * - Only keys in `ALLOWED_KEYS` are included.
262
+ * - String values are scanned for secret patterns and replaced with "***".
263
+ * - `ts` and `level` and `event` are always included (they are injected by the
264
+ * logger functions below, not by callers).
265
+ */
266
+ function buildPayload(level, event, fields) {
267
+ const out = {
268
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
269
+ level,
270
+ event
271
+ };
272
+ for (const [key, value] of Object.entries(fields)) {
273
+ if (!ALLOWED_KEYS.has(key)) continue;
274
+ if (key === "ts" || key === "level" || key === "event") continue;
275
+ out[key] = redactValue(value);
276
+ }
277
+ return out;
278
+ }
279
+ /**
280
+ * Writes a single JSON log line to stderr.
281
+ * MCP stdio transport uses stdout; all diagnostics go to stderr.
282
+ */
283
+ function writeLog(level, event, fields = {}) {
284
+ const payload = buildPayload(level, event, fields);
285
+ process.stderr.write(`${JSON.stringify(payload)}\n`);
286
+ }
287
+ /** Log an informational structured event. */
288
+ function logInfo(event, fields = {}) {
289
+ writeLog("info", event, fields);
290
+ }
291
+ /** Log an error structured event. */
292
+ function logError(event, fields = {}) {
293
+ writeLog("error", event, fields);
294
+ }
295
+ //#endregion
296
+ //#region src/mcp/chii-connection.ts
297
+ /**
298
+ * Production `CdpConnection` backed by the local Chii relay.
299
+ *
300
+ * Topology (debug mode):
301
+ * phone target.js --WS--> Chii relay :9100 <--WS-- this connection
302
+ *
303
+ * The phone connects to the relay as a `target`; this module connects as a
304
+ * `client` (the role a CDP frontend would take) so CDP events the page emits
305
+ * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
306
+ * events in ring buffers the tool layer reads via `getBufferedEvents`.
307
+ *
308
+ * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
309
+ *
310
+ * Attach reliability (#281):
311
+ * `refreshTargets()` emits an internal 'target:attached' event whenever a
312
+ * new target is added to the relay. `waitForFirstTarget()` awaits that event
313
+ * (with a polling-interval fallback) so `start_attach`'s attach wait
314
+ * resolves deterministically rather than racing between polling rounds.
315
+ */
316
+ /** Max events retained per domain ring buffer. */
317
+ const DEFAULT_BUFFER_SIZE = 500;
318
+ /**
319
+ * Substrings that mark a "relay websocket is dead" class error, as produced by
320
+ * `handleDisconnect('relay WebSocket 연결이 끊겼습니다')` (ws close handler) and
321
+ * the fail-fast `sendCommand` rejection (`relay에 연결되어 있지 않습니다 (...)`).
322
+ *
323
+ * Exported so callers outside this module (e.g. `test-runner/relay-worker.ts`)
324
+ * can detect the same error class without hardcoding a fragile full-sentence
325
+ * match — mirrors the `EVALUATE_TIMEOUT_MARKER` precedent in
326
+ * `test-runner/relay-worker.ts`. Kept in sync with `classifyToolError`'s
327
+ * `relayDisconnectError` branch in `mcp/errors.ts`.
328
+ */
329
+ const RELAY_DISCONNECT_MARKERS = ["relay WebSocket", "relay에 연결되어 있지 않습니다"];
330
+ /**
331
+ * Returns true when `message` matches the relay-websocket-dead error class
332
+ * (socket closed, socket errored, or fail-fast "not connected" rejection).
333
+ */
334
+ function isRelayDisconnectMessage(message) {
335
+ return RELAY_DISCONNECT_MARKERS.some((marker) => message.includes(marker));
336
+ }
337
+ function isObject(value) {
338
+ return typeof value === "object" && value !== null;
339
+ }
340
+ function parseInbound(raw) {
341
+ let parsed;
342
+ try {
343
+ parsed = JSON.parse(raw);
344
+ } catch {
345
+ return null;
346
+ }
347
+ if (!isObject(parsed)) return null;
348
+ const message = {};
349
+ if (typeof parsed.id === "number") message.id = parsed.id;
350
+ if (typeof parsed.method === "string") message.method = parsed.method;
351
+ if ("params" in parsed) message.params = parsed.params;
352
+ if ("result" in parsed) message.result = parsed.result;
353
+ if (isObject(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
354
+ return message;
355
+ }
356
+ const PHASE_1_EVENTS = [
357
+ "Runtime.consoleAPICalled",
358
+ "Network.requestWillBeSent",
359
+ "Network.responseReceived"
360
+ ];
361
+ /**
362
+ * Ring buffer size for `Runtime.exceptionThrown`.
363
+ *
364
+ * Exceptions are rarer than console messages but each is heavier (stack
365
+ * trace). 50 is generous enough to cover a crash scenario while keeping
366
+ * memory bounded.
367
+ *
368
+ * **Lifecycle note**: the exception buffer intentionally survives `replaced` /
369
+ * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
370
+ * transitions. Rationale: an exception fired just before a crash is exactly
371
+ * the signal we want to preserve for root-cause analysis. The buffer
372
+ * represents "exceptions seen in this MCP session", not "exceptions in the
373
+ * current page".
374
+ */
375
+ const EXCEPTION_BUFFER_SIZE = 50;
376
+ /** Default per-command timeout if neither option nor env var is set. */
377
+ const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
378
+ /**
379
+ * Production CDP connection. Polls the relay for the first attached target,
380
+ * opens a client websocket to it, enables Phase 1 domains, and buffers events.
381
+ */
382
+ var ChiiCdpConnection = class {
383
+ /** Authoritative connection kind (issue #348) — relay-backed. */
384
+ kind = "relay";
385
+ relayBaseUrl;
386
+ bufferSize;
387
+ commandTimeoutMs;
388
+ totpSecret;
389
+ emitter = new EventEmitter();
390
+ buffers = /* @__PURE__ */ new Map();
391
+ targets = /* @__PURE__ */ new Map();
392
+ ws = null;
393
+ connectionState = "idle";
394
+ nextCommandId = 1;
395
+ /**
396
+ * The single active target id under the single-attach model.
397
+ * Updated by `refreshTargets()` whenever a non-null target is present.
398
+ * Used to detect a new (different) target attach and evict the previous one.
399
+ */
400
+ activeTargetId = null;
401
+ /** In-flight enableDomains() promise — concurrent callers share it. */
402
+ enablingPromise = null;
403
+ /** Pending request→response commands keyed by CDP message id. */
404
+ pending = /* @__PURE__ */ new Map();
405
+ /**
406
+ * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
407
+ * or `null` if no crash has been detected since the last `enableDomains()`.
408
+ */
409
+ lastCrashDetectedAt = null;
410
+ /**
411
+ * Per-target last-seen timestamp (ms since epoch). Updated on any inbound
412
+ * CDP message carrying data from a target. Keyed by target id.
413
+ */
414
+ targetLastSeenAt = /* @__PURE__ */ new Map();
415
+ /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
416
+ heartbeatHandle = null;
417
+ /** Lifecycle event listeners (crash / destroyed / detached). */
418
+ lifecycleListeners = [];
419
+ constructor(options) {
420
+ this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
421
+ this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
422
+ this.totpSecret = options.totpSecret;
423
+ const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
424
+ this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
425
+ for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);
426
+ this.buffers.set("Runtime.exceptionThrown", []);
427
+ this.emitter.setMaxListeners(0);
428
+ }
429
+ /** Refresh the attached-target list from the relay's `GET /targets`. */
430
+ async refreshTargets() {
431
+ let targetsUrl = `${this.relayBaseUrl}/targets`;
432
+ if (this.totpSecret) {
433
+ const code = generateTotp(this.totpSecret);
434
+ targetsUrl += `?at=${encodeURIComponent(code)}`;
435
+ }
436
+ const res = await fetch(targetsUrl);
437
+ if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
438
+ const body = await res.json();
439
+ const list = isObject(body) && Array.isArray(body.targets) ? body.targets : [];
440
+ let newestTargetId = null;
441
+ for (const item of list) {
442
+ if (!isObject(item) || typeof item.id !== "string") continue;
443
+ newestTargetId = item.id;
444
+ }
445
+ if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
446
+ const prevId = this.activeTargetId;
447
+ logInfo("page.detached", { prevTargetId: prevId });
448
+ this.evictTarget(prevId);
449
+ }
450
+ this.targets.clear();
451
+ for (const item of list) {
452
+ if (!isObject(item) || typeof item.id !== "string") continue;
453
+ if (item.id !== newestTargetId) continue;
454
+ this.targets.set(item.id, {
455
+ id: item.id,
456
+ title: typeof item.title === "string" ? item.title : "",
457
+ url: typeof item.url === "string" ? item.url : ""
458
+ });
459
+ }
460
+ if (newestTargetId !== null) this.activeTargetId = newestTargetId;
461
+ else this.activeTargetId = null;
462
+ const result = [...this.targets.values()];
463
+ if (newestTargetId !== null) this.emitter.emit("target:attached", result);
464
+ return result;
465
+ }
466
+ listTargets() {
467
+ return [...this.targets.values()];
468
+ }
469
+ /**
470
+ * Waits until at least one target matching `filterFn` is attached, then
471
+ * resolves with the full target list at that moment.
472
+ *
473
+ * Resolution happens on whichever comes first:
474
+ * (a) a `'target:attached'` event from `refreshTargets()` (triggered by
475
+ * the /targets poll finding a new target), OR
476
+ * (b) a `'target:attached'` event from `handleMessage()` (triggered by
477
+ * the first inbound CDP message from a target — confirms the relay
478
+ * websocket has data from the phone, not just a target entry in the map).
479
+ *
480
+ * This dual-signal approach eliminates the polling race that previously
481
+ * caused `wait_for_attach` to resolve before the first CDP message arrived.
482
+ *
483
+ * Falls back to checking `listTargets()` every `pollIntervalMs` in case the
484
+ * EventEmitter is missed (defensive belt-and-suspenders).
485
+ *
486
+ * @param filterFn - Predicate that the returned targets must satisfy.
487
+ * @param timeoutMs - Reject after this many ms (default 90 000).
488
+ * @param pollIntervalMs - Fallback poll interval (default 500ms).
489
+ */
490
+ waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
491
+ const current = this.listTargets();
492
+ if (filterFn(current)) return Promise.resolve(current);
493
+ return new Promise((resolve, reject) => {
494
+ let settled = false;
495
+ let pollHandle = null;
496
+ const settle = (targets) => {
497
+ if (settled) return;
498
+ settled = true;
499
+ clearTimeout(timeoutHandle);
500
+ if (pollHandle !== null) {
501
+ clearInterval(pollHandle);
502
+ pollHandle = null;
503
+ }
504
+ this.emitter.off("target:attached", onAttach);
505
+ resolve(targets);
506
+ };
507
+ const onAttach = (targets) => {
508
+ if (filterFn(targets)) settle(targets);
509
+ };
510
+ const timeoutHandle = setTimeout(() => {
511
+ if (settled) return;
512
+ settled = true;
513
+ if (pollHandle !== null) {
514
+ clearInterval(pollHandle);
515
+ pollHandle = null;
516
+ }
517
+ this.emitter.off("target:attached", onAttach);
518
+ reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
519
+ }, timeoutMs);
520
+ this.emitter.on("target:attached", onAttach);
521
+ pollHandle = setInterval(() => {
522
+ this.refreshTargets().then((targets) => {
523
+ if (filterFn(targets)) settle(targets);
524
+ }, () => {});
525
+ }, pollIntervalMs);
526
+ });
527
+ }
528
+ /**
529
+ * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
530
+ * detected since the last `enableDomains()` call, or `null` if none.
531
+ */
532
+ getLastCrashDetectedAt() {
533
+ return this.lastCrashDetectedAt;
534
+ }
535
+ /**
536
+ * Last-seen timestamp (ms since epoch) for a given target id, or `null` if
537
+ * the target is unknown / no message has been received from it yet.
538
+ */
539
+ getTargetLastSeenAt(targetId) {
540
+ return this.targetLastSeenAt.get(targetId) ?? null;
541
+ }
542
+ /** Subscribe to target lifecycle events (crash / destroyed / detached). */
543
+ onLifecycle(listener) {
544
+ this.lifecycleListeners.push(listener);
545
+ return () => {
546
+ const idx = this.lifecycleListeners.indexOf(listener);
547
+ if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
548
+ };
549
+ }
550
+ /**
551
+ * Connect a client websocket to the first attached target and enable Phase 1
552
+ * domains. Resolves once the socket is open and enable commands are sent.
553
+ */
554
+ async enableDomains() {
555
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
556
+ if (this.enablingPromise) return this.enablingPromise;
557
+ this.enablingPromise = this._doEnableDomains().finally(() => {
558
+ this.enablingPromise = null;
559
+ });
560
+ return this.enablingPromise;
561
+ }
562
+ async _doEnableDomains() {
563
+ const target = (await this.refreshTargets())[0];
564
+ if (!target) throw new Error("No mini-app page attached to the Chii relay yet.");
565
+ let clientUrl = `${this.relayBaseUrl.replace(/^http/, "ws")}/client/${`devtools-mcp-${Date.now()}`}?target=${encodeURIComponent(target.id)}`;
566
+ if (this.totpSecret) {
567
+ const code = generateTotp(this.totpSecret);
568
+ clientUrl += `&at=${encodeURIComponent(code)}`;
569
+ }
570
+ const ws = new WebSocket(clientUrl);
571
+ this.ws = ws;
572
+ await new Promise((resolve, reject) => {
573
+ ws.once("open", () => resolve());
574
+ ws.once("error", (err) => reject(err));
575
+ ws.once("close", (code) => {
576
+ if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
577
+ });
578
+ });
579
+ this.lastCrashDetectedAt = null;
580
+ this.targetLastSeenAt.clear();
581
+ this.connectionState = "connected";
582
+ ws.on("message", (data) => this.handleMessage(data.toString()));
583
+ ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
584
+ ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
585
+ this.sendFireAndForget("Runtime.enable");
586
+ this.sendFireAndForget("Network.enable");
587
+ this.sendFireAndForget("DOM.enable");
588
+ this.sendFireAndForget("Page.enable");
589
+ this.sendFireAndForget("Inspector.enable");
590
+ this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
591
+ this.startHeartbeat(target.id);
592
+ }
593
+ /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
594
+ sendFireAndForget(method, params = {}) {
595
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
596
+ const id = this.nextCommandId++;
597
+ this.ws.send(JSON.stringify({
598
+ id,
599
+ method,
600
+ params
601
+ }));
602
+ }
603
+ /**
604
+ * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
605
+ * error frame or when no websocket is open (no page attached yet).
606
+ */
607
+ send(method, params) {
608
+ return this.sendCommand(method, params ?? {});
609
+ }
610
+ /**
611
+ * Issue an arbitrary request→response command over the relay and resolve with
612
+ * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
613
+ * `AIT.*` methods, forwarded over the same Chii channel) build on this.
614
+ *
615
+ * Rejects immediately if the connection is disconnected (fail-fast — no
616
+ * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
617
+ * reattach.
618
+ *
619
+ * Times out after `commandTimeoutMs` (default 30s, env
620
+ * `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
621
+ * and the promise rejects with a descriptive Korean error.
622
+ */
623
+ sendCommand(method, params = {}) {
624
+ if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
625
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No mini-app page attached to the Chii relay yet. Call enableDomains() first."));
626
+ const id = this.nextCommandId++;
627
+ const ws = this.ws;
628
+ const timeoutMs = this.commandTimeoutMs;
629
+ return new Promise((resolve, reject) => {
630
+ const handle = setTimeout(() => {
631
+ this.pending.delete(id);
632
+ reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
633
+ }, timeoutMs);
634
+ this.pending.set(id, {
635
+ resolve: (v) => {
636
+ clearTimeout(handle);
637
+ resolve(v);
638
+ },
639
+ reject: (e) => {
640
+ clearTimeout(handle);
641
+ reject(e);
642
+ }
643
+ });
644
+ ws.send(JSON.stringify({
645
+ id,
646
+ method,
647
+ params
648
+ }));
649
+ });
650
+ }
651
+ /**
652
+ * Called on WebSocket `close` or `error` after a successful connection.
653
+ * Rejects all pending commands and marks the connection as disconnected so
654
+ * subsequent `sendCommand` calls fail fast (no auto-reconnect).
655
+ */
656
+ handleDisconnect(reason) {
657
+ if (this.connectionState === "disconnected") return;
658
+ this.connectionState = "disconnected";
659
+ this.ws = null;
660
+ this.stopHeartbeat();
661
+ const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
662
+ for (const waiter of this.pending.values()) waiter.reject(err);
663
+ this.pending.clear();
664
+ }
665
+ /**
666
+ * Evict a previously active target under the single-attach model.
667
+ * Rejects pending commands with a 'replaced-by-new-attach' reason and emits
668
+ * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
669
+ * targetId. The caller is responsible for rebuilding the targets map afterwards.
670
+ *
671
+ * The error message uses 'replaced-by-new-attach' so test assertions can match it.
672
+ */
673
+ evictTarget(targetId) {
674
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
675
+ this.targets.delete(targetId);
676
+ this.targetLastSeenAt.delete(targetId);
677
+ const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
678
+ for (const waiter of this.pending.values()) waiter.reject(err);
679
+ this.pending.clear();
680
+ const event = {
681
+ kind: "replaced",
682
+ targetId,
683
+ detectedAt
684
+ };
685
+ for (const listener of this.lifecycleListeners) try {
686
+ listener(event);
687
+ } catch {}
688
+ }
689
+ /**
690
+ * Handle a page-level crash or target destruction event.
691
+ * Removes the target from the in-memory map, rejects all pending commands,
692
+ * and emits a lifecycle event.
693
+ *
694
+ * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
695
+ * @param targetId - The target ID from the event params (may be null for
696
+ * Inspector.targetCrashed which has no targetId in the params).
697
+ */
698
+ handleTargetGone(kind, targetId) {
699
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
700
+ this.lastCrashDetectedAt = Date.now();
701
+ if (targetId !== null) {
702
+ this.targets.delete(targetId);
703
+ this.targetLastSeenAt.delete(targetId);
704
+ if (this.activeTargetId === targetId) this.activeTargetId = null;
705
+ } else {
706
+ this.targets.clear();
707
+ this.targetLastSeenAt.clear();
708
+ this.activeTargetId = null;
709
+ }
710
+ const err = /* @__PURE__ */ new Error(`[ait-debug] ${kind === "crashed" ? "page crash (Inspector.targetCrashed)" : kind === "destroyed" ? "target 종료 (Target.targetDestroyed)" : "target detach (Target.detachedFromTarget)"} 감지됨 — relay에서 제거됐습니다. 새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).`);
711
+ for (const waiter of this.pending.values()) waiter.reject(err);
712
+ this.pending.clear();
713
+ const event = {
714
+ kind,
715
+ targetId,
716
+ detectedAt
717
+ };
718
+ for (const listener of this.lifecycleListeners) try {
719
+ listener(event);
720
+ } catch {}
721
+ }
722
+ /**
723
+ * Start the optional CDP heartbeat loop.
724
+ *
725
+ * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
726
+ * we send `Runtime.evaluate({expression: '1'})` to each active target. If
727
+ * the command times out (2 s hard deadline) or errors, we treat the target
728
+ * as dead and call `handleTargetGone`.
729
+ *
730
+ * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
731
+ * even when the phone app has crashed, so the ws-level disconnect (#252) won't
732
+ * fire. The heartbeat catches this gap.
733
+ *
734
+ * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
735
+ */
736
+ startHeartbeat(initialTargetId) {
737
+ this.stopHeartbeat();
738
+ const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
739
+ if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
740
+ const PING_TIMEOUT_MS = 2e3;
741
+ this.heartbeatHandle = setInterval(() => {
742
+ const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
743
+ for (const targetId of targetIds) {
744
+ const pingPromise = this.sendCommand("Runtime.evaluate", {
745
+ expression: "1",
746
+ returnByValue: true,
747
+ timeout: PING_TIMEOUT_MS
748
+ });
749
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
750
+ Promise.race([pingPromise, timeoutPromise]).catch(() => {
751
+ if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
752
+ });
753
+ }
754
+ }, envMs);
755
+ }
756
+ stopHeartbeat() {
757
+ if (this.heartbeatHandle !== null) {
758
+ clearInterval(this.heartbeatHandle);
759
+ this.heartbeatHandle = null;
760
+ }
761
+ }
762
+ handleMessage(raw) {
763
+ const message = parseInbound(raw);
764
+ if (!message) return;
765
+ if (typeof message.id === "number" && this.pending.has(message.id)) {
766
+ const waiter = this.pending.get(message.id);
767
+ this.pending.delete(message.id);
768
+ if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
769
+ else waiter.resolve(message.result);
770
+ return;
771
+ }
772
+ const now = Date.now();
773
+ let firstMessageSeen = false;
774
+ for (const targetId of this.targets.keys()) {
775
+ if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
776
+ this.targetLastSeenAt.set(targetId, now);
777
+ }
778
+ if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
779
+ if (typeof message.method !== "string") return;
780
+ if (message.method === "Inspector.targetCrashed") {
781
+ this.handleTargetGone("crashed", null);
782
+ return;
783
+ }
784
+ if (message.method === "Target.targetDestroyed") {
785
+ const targetId = isObject(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
786
+ this.handleTargetGone("destroyed", targetId);
787
+ return;
788
+ }
789
+ if (message.method === "Target.detachedFromTarget") {
790
+ const targetId = isObject(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
791
+ this.handleTargetGone("detached", targetId);
792
+ return;
793
+ }
794
+ if (!this.buffers.has(message.method)) return;
795
+ const event = message.method;
796
+ const buffer = this.buffers.get(event);
797
+ if (!buffer) return;
798
+ buffer.push(message.params);
799
+ const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
800
+ if (buffer.length > cap) buffer.shift();
801
+ this.emitter.emit(event, message.params);
802
+ }
803
+ getBufferedEvents(event) {
804
+ return this.buffers.get(event) ?? [];
805
+ }
806
+ on(event, listener) {
807
+ this.emitter.on(event, listener);
808
+ return () => this.emitter.off(event, listener);
809
+ }
810
+ /** Close the relay client websocket and reject any in-flight commands. */
811
+ close() {
812
+ const ws = this.ws;
813
+ this.stopHeartbeat();
814
+ this.handleDisconnect("Chii relay connection closed");
815
+ ws?.close();
816
+ }
817
+ };
818
+ //#endregion
160
819
  //#region src/test-runner/bundle.ts
161
820
  /**
162
821
  * esbuild-based bundler for user test files.
@@ -549,7 +1208,7 @@ function parseCaptureLines(raw) {
549
1208
  //#endregion
550
1209
  //#region src/test-runner/rpc.ts
551
1210
  /** Maximum milliseconds to wait for a single evaluate round-trip. */
552
- const DEFAULT_TIMEOUT_MS = 3e4;
1211
+ const DEFAULT_TIMEOUT_MS = 6e4;
553
1212
  /**
554
1213
  * Wraps bundle code in a self-executing IIFE that:
555
1214
  * 1. Evaluates the bundle (registering describe/it/test).
@@ -608,13 +1267,22 @@ function parseRunTestsResult(rawValue) {
608
1267
  */
609
1268
  async function injectAndRunBundle(connection, bundleCode, timeoutMs = DEFAULT_TIMEOUT_MS) {
610
1269
  const expression = buildRunTestsExpression(bundleCode);
611
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`rpc: evaluate timed out after ${timeoutMs}ms`)), timeoutMs));
1270
+ const TIMEOUT_SENTINEL = Symbol("timeout");
1271
+ const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs));
612
1272
  const evalPromise = connection.send("Runtime.evaluate", {
613
1273
  expression,
614
1274
  returnByValue: true,
615
1275
  awaitPromise: true
616
1276
  });
617
- const cdpResult = await Promise.race([evalPromise, timeoutPromise]);
1277
+ const raceResult = await Promise.race([evalPromise.then((v) => ({
1278
+ tag: "eval",
1279
+ v
1280
+ })), timeoutPromise.then(() => ({ tag: "timeout" }))]);
1281
+ if (raceResult.tag === "timeout") return {
1282
+ ok: false,
1283
+ error: `rpc: evaluate timed out after ${timeoutMs}ms`
1284
+ };
1285
+ const cdpResult = raceResult.v;
618
1286
  if (cdpResult.exceptionDetails) {
619
1287
  const msg = cdpResult.exceptionDetails.exception?.description ?? cdpResult.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
620
1288
  throw new Error(`rpc.injectAndRunBundle: ${msg}`);
@@ -669,8 +1337,37 @@ async function runTestFilesOverRelay(connection, files, opts) {
669
1337
  if (collectCaptures && domainsEnabled) unsubscribeConsole = connection.on("Runtime.consoleAPICalled", (event) => {
670
1338
  liveConsole.push(event);
671
1339
  });
1340
+ let pendingReconnectCheck = false;
1341
+ /**
1342
+ * Attempts one `enableDomains()` reconnect. Logs the attempt and outcome to
1343
+ * stderr. Never throws — a failed reconnect just means the caller proceeds
1344
+ * as today (each remaining file / the retry will fail-fast on the still-dead
1345
+ * socket).
1346
+ *
1347
+ * `context` selects the fixed log literal: 'next-file' for the between-files
1348
+ * case (a file's FINAL result was a WS-dead-class error — the primary #731
1349
+ * scenario), 'retry-precheck' for the defensive reconnect before retrying a
1350
+ * timed-out file (cheap no-op via `enableDomains()`'s idempotency when the
1351
+ * socket never actually died).
1352
+ *
1353
+ * SECRET-HANDLING: fixed literals only — no relay wss URL, TOTP code, or
1354
+ * tunnel host.
1355
+ */
1356
+ const attemptReconnect = async (context) => {
1357
+ process.stderr.write(context === "next-file" ? "relay-worker: relay connection lost — attempting reconnect before next file\n" : "relay-worker: evaluate timed out — attempting reconnect before retry\n");
1358
+ try {
1359
+ await connection.enableDomains();
1360
+ process.stderr.write("relay-worker: reconnect succeeded\n");
1361
+ } catch (e) {
1362
+ process.stderr.write(`relay-worker: reconnect failed (${e instanceof Error ? e.message : String(e)}) — continuing, remaining files may fail\n`);
1363
+ }
1364
+ };
672
1365
  try {
673
1366
  for (const file of files) {
1367
+ if (pendingReconnectCheck) {
1368
+ await attemptReconnect("next-file");
1369
+ pendingReconnectCheck = false;
1370
+ }
674
1371
  let fileEntry;
675
1372
  try {
676
1373
  const { code } = await bundleTestFile(file, opts?.bundleOptions);
@@ -679,12 +1376,19 @@ async function runTestFilesOverRelay(connection, files, opts) {
679
1376
  * result is a genuine timeout and the caller should retry.
680
1377
  *
681
1378
  * We need to distinguish:
682
- * - `rpcResult.ok = false` + timeout error → retry candidate
1379
+ * - `rpcResult.ok = false` + timeout error → retry candidate (`return null`)
683
1380
  * - `rpcResult.ok = false` + other error → final error, no retry
684
- * - `injectAndRunBundle` throws → treated like a final error
685
- * (throws happen on CDP exceptionDetails, not on the Promise.race
686
- * timeout the timeout produces `rpcResult.ok=false` with the
687
- * EVALUATE_TIMEOUT_MARKER message)
1381
+ * - `injectAndRunBundle` throws → CDP exceptionDetails OR a
1382
+ * relay-disconnect rejection from `connection.send()` (page engine
1383
+ * threw, or the ws died mid-evaluate); treated as a final
1384
+ * (non-retryable) error either way.
1385
+ *
1386
+ * The Promise.race timeout in rpc.ts RETURNS `{ok:false, error: '…'}` (it
1387
+ * does NOT throw/reject). Only genuine CDP `exceptionDetails` (or a dead
1388
+ * `connection.send()`) cause a throw. This distinction is what makes the
1389
+ * EVALUATE_TIMEOUT_MARKER gate below reachable — the timeout result
1390
+ * surfaces as `rpcResult.ok=false` with the marker string, not as a
1391
+ * caught exception.
688
1392
  */
689
1393
  const attempt = async () => {
690
1394
  let rpcResult;
@@ -709,12 +1413,13 @@ async function runTestFilesOverRelay(connection, files, opts) {
709
1413
  const firstResult = await attempt();
710
1414
  if (firstResult !== null) fileEntry = firstResult;
711
1415
  else {
1416
+ await attemptReconnect("retry-precheck");
712
1417
  process.stderr.write(`relay-worker: evaluate timed out for ${file} — retrying once\n`);
713
1418
  const retryResult = await attempt();
714
1419
  if (retryResult !== null) fileEntry = retryResult;
715
1420
  else fileEntry = {
716
1421
  file,
717
- result: { error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 3e4}ms (after retry)` }
1422
+ result: { error: `${EVALUATE_TIMEOUT_MARKER} ${opts?.timeoutMs ?? 6e4}ms (after retry)` }
718
1423
  };
719
1424
  }
720
1425
  } catch (e) {
@@ -723,6 +1428,7 @@ async function runTestFilesOverRelay(connection, files, opts) {
723
1428
  result: { error: e instanceof Error ? e.message : String(e) }
724
1429
  };
725
1430
  }
1431
+ if ("error" in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) pendingReconnectCheck = true;
726
1432
  fileResults.push(fileEntry);
727
1433
  }
728
1434
  } finally {
@@ -937,7 +1643,7 @@ USAGE
937
1643
  OPTIONS
938
1644
  --scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
939
1645
  (required for standalone relay attach / env3)
940
- --timeout <ms> Per-file evaluate timeout in ms (default: 30000).
1646
+ --timeout <ms> Per-file evaluate timeout in ms (default: 60000).
941
1647
  Controls how long a single test file is allowed to run
942
1648
  before it is considered hung. Does NOT affect how long
943
1649
  the CLI waits for a human to scan the QR code — use
@@ -993,7 +1699,7 @@ EXAMPLE
993
1699
  * Exported for unit testing — main() is the only other caller.
994
1700
  */
995
1701
  function resolveTimeouts(rawTimeout, rawAttachTimeout) {
996
- const evaluateTimeoutMs = rawTimeout !== void 0 ? parseInt(rawTimeout, 10) : 3e4;
1702
+ const evaluateTimeoutMs = rawTimeout !== void 0 ? parseInt(rawTimeout, 10) : 6e4;
997
1703
  if (Number.isNaN(evaluateTimeoutMs) || evaluateTimeoutMs <= 0) return "--timeout must be a positive integer";
998
1704
  const attachTimeoutMs = rawAttachTimeout !== void 0 ? parseInt(rawAttachTimeout, 10) : void 0;
999
1705
  if (attachTimeoutMs !== void 0 && (Number.isNaN(attachTimeoutMs) || attachTimeoutMs <= 0)) return "--attach-timeout must be a positive integer";
@@ -1234,6 +1940,6 @@ main().catch((e) => {
1234
1940
  process.exitCode = 1;
1235
1941
  });
1236
1942
  //#endregion
1237
- export {};
1943
+ export { RELAY_AUTH_REJECT_REASON as a, RELAY_AUTH_REJECT_CLOSE_CODE as i, logError as n, logInfo as r, ChiiCdpConnection as t };
1238
1944
 
1239
1945
  //# sourceMappingURL=bin.js.map