@ait-co/devtools 0.1.41 → 0.1.43

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/dist/mcp/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { existsSync, realpathSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { argv } from "node:process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -11,9 +11,13 @@ import { WebSocket } from "ws";
11
11
  import { createServer } from "node:http";
12
12
  import { spawn } from "node:child_process";
13
13
  import net from "node:net";
14
- import { platform } from "node:os";
14
+ import { homedir, platform } from "node:os";
15
+ import { join } from "node:path";
15
16
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
16
17
  import { Tunnel, bin, install } from "cloudflared";
18
+ //#region \0rolldown/runtime.js
19
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
+ //#endregion
17
21
  //#region src/mcp/ait-chii-source.ts
18
22
  function isObject$4(value) {
19
23
  return typeof value === "object" && value !== null;
@@ -62,6 +66,12 @@ var ChiiAitSource = class {
62
66
  * events in ring buffers the tool layer reads via `getBufferedEvents`.
63
67
  *
64
68
  * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
69
+ *
70
+ * Attach reliability (#281):
71
+ * `refreshTargets()` emits an internal 'target:attached' event whenever a
72
+ * new target is added to the relay. `waitForFirstTarget()` awaits that event
73
+ * (with a polling-interval fallback) so `build_attach_url wait_for_attach`
74
+ * resolves deterministically rather than racing between polling rounds.
65
75
  */
66
76
  /** Max events retained per domain ring buffer. */
67
77
  const DEFAULT_BUFFER_SIZE$1 = 500;
@@ -181,12 +191,73 @@ var ChiiCdpConnection = class {
181
191
  }
182
192
  if (newestTargetId !== null) this.activeTargetId = newestTargetId;
183
193
  else this.activeTargetId = null;
184
- return [...this.targets.values()];
194
+ const result = [...this.targets.values()];
195
+ if (newestTargetId !== null) this.emitter.emit("target:attached", result);
196
+ return result;
185
197
  }
186
198
  listTargets() {
187
199
  return [...this.targets.values()];
188
200
  }
189
201
  /**
202
+ * Waits until at least one target matching `filterFn` is attached, then
203
+ * resolves with the full target list at that moment.
204
+ *
205
+ * Resolution happens on whichever comes first:
206
+ * (a) a `'target:attached'` event from `refreshTargets()` (triggered by
207
+ * the /targets poll finding a new target), OR
208
+ * (b) a `'target:attached'` event from `handleMessage()` (triggered by
209
+ * the first inbound CDP message from a target — confirms the relay
210
+ * websocket has data from the phone, not just a target entry in the map).
211
+ *
212
+ * This dual-signal approach eliminates the polling race that previously
213
+ * caused `wait_for_attach` to resolve before the first CDP message arrived.
214
+ *
215
+ * Falls back to checking `listTargets()` every `pollIntervalMs` in case the
216
+ * EventEmitter is missed (defensive belt-and-suspenders).
217
+ *
218
+ * @param filterFn - Predicate that the returned targets must satisfy.
219
+ * @param timeoutMs - Reject after this many ms (default 90 000).
220
+ * @param pollIntervalMs - Fallback poll interval (default 500ms).
221
+ */
222
+ waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
223
+ const current = this.listTargets();
224
+ if (filterFn(current)) return Promise.resolve(current);
225
+ return new Promise((resolve, reject) => {
226
+ let settled = false;
227
+ let pollHandle = null;
228
+ const settle = (targets) => {
229
+ if (settled) return;
230
+ settled = true;
231
+ clearTimeout(timeoutHandle);
232
+ if (pollHandle !== null) {
233
+ clearInterval(pollHandle);
234
+ pollHandle = null;
235
+ }
236
+ this.emitter.off("target:attached", onAttach);
237
+ resolve(targets);
238
+ };
239
+ const onAttach = (targets) => {
240
+ if (filterFn(targets)) settle(targets);
241
+ };
242
+ const timeoutHandle = setTimeout(() => {
243
+ if (settled) return;
244
+ settled = true;
245
+ if (pollHandle !== null) {
246
+ clearInterval(pollHandle);
247
+ pollHandle = null;
248
+ }
249
+ this.emitter.off("target:attached", onAttach);
250
+ reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
251
+ }, timeoutMs);
252
+ this.emitter.on("target:attached", onAttach);
253
+ pollHandle = setInterval(() => {
254
+ this.refreshTargets().then((targets) => {
255
+ if (filterFn(targets)) settle(targets);
256
+ }, () => {});
257
+ }, pollIntervalMs);
258
+ });
259
+ }
260
+ /**
190
261
  * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
191
262
  * detected since the last `enableDomains()` call, or `null` if none.
192
263
  */
@@ -423,7 +494,12 @@ var ChiiCdpConnection = class {
423
494
  return;
424
495
  }
425
496
  const now = Date.now();
426
- for (const targetId of this.targets.keys()) this.targetLastSeenAt.set(targetId, now);
497
+ let firstMessageSeen = false;
498
+ for (const targetId of this.targets.keys()) {
499
+ if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
500
+ this.targetLastSeenAt.set(targetId, now);
501
+ }
502
+ if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
427
503
  if (typeof message.method !== "string") return;
428
504
  if (message.method === "Inspector.targetCrashed") {
429
505
  this.handleTargetGone("crashed", null);
@@ -493,9 +569,9 @@ var ChiiCdpConnection = class {
493
569
  * in any log, error message, or process output. `verifyAuth` is a black-box
494
570
  * predicate from the caller's perspective; this module only forwards pass/fail.
495
571
  */
496
- const require = createRequire(import.meta.url);
572
+ const require$1 = createRequire(import.meta.url);
497
573
  function loadChiiServer() {
498
- const mod = require("chii");
574
+ const mod = require$1("chii");
499
575
  if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
500
576
  throw new Error("chii server module did not expose start()");
501
577
  }
@@ -548,6 +624,206 @@ async function startChiiRelay(options = {}) {
548
624
  };
549
625
  }
550
626
  //#endregion
627
+ //#region src/mcp/devtools-opener.ts
628
+ /**
629
+ * Base URL for the Chrome DevTools inspector hosted on appspot.
630
+ *
631
+ * The `@` path segment is the "latest / bleeding edge" alias which tracks the
632
+ * current Chrome stable CDP protocol version — compatible with the chobitsu-
633
+ * based CDP that Chii injects. A specific commit hash may be pinned here if
634
+ * a regression is observed.
635
+ */
636
+ const DEVTOOLS_FRONTEND_BASE = "https://chrome-devtools-frontend.appspot.com/serve_file/@/inspector.html";
637
+ /**
638
+ * Assembles the Chrome DevTools inspector URL that connects to a Chii relay
639
+ * WebSocket.
640
+ *
641
+ * The `wss=` parameter expects a host-and-path string without the `wss://`
642
+ * scheme prefix — the DevTools frontend prepends it automatically.
643
+ *
644
+ * @param wssRelayUrl - Full `wss://` URL of the Chii relay (public tunnel).
645
+ * Example: `wss://abc.trycloudflare.com`
646
+ * @param panel - Initial panel. Defaults to `"console"`.
647
+ *
648
+ * @example
649
+ * buildChromeDevtoolsUrl('wss://abc.trycloudflare.com')
650
+ * // → 'https://chrome-devtools-frontend.appspot.com/serve_file/@/inspector.html?wss=abc.trycloudflare.com&panel=console'
651
+ */
652
+ function buildChromeDevtoolsUrl(wssRelayUrl, panel = "console") {
653
+ const wssParam = wssRelayUrl.replace(/^wss:\/\//i, "");
654
+ return `${DEVTOOLS_FRONTEND_BASE}?${new URLSearchParams({
655
+ wss: wssParam,
656
+ panel
657
+ }).toString()}`;
658
+ }
659
+ /**
660
+ * Returns `true` when auto-open is **disabled** via the `AIT_AUTO_DEVTOOLS`
661
+ * env var. Only the explicit `"0"` value disables it; anything else (including
662
+ * absent) leaves auto-open enabled.
663
+ */
664
+ function isAutoDevtoolsDisabled() {
665
+ return process.env.AIT_AUTO_DEVTOOLS === "0";
666
+ }
667
+ /**
668
+ * Opens the given URL in the OS default browser using a platform-appropriate
669
+ * command. Returns `true` on success.
670
+ *
671
+ * Failures are silent from the caller's perspective — the caller should log
672
+ * the URL to stderr as a fallback before calling this function.
673
+ */
674
+ function openUrlInBrowser(url) {
675
+ if (process.env.AIT_AUTO_DEVTOOLS_TEST_SKIP_SPAWN === "1") return false;
676
+ const { spawnSync } = __require("node:child_process");
677
+ const platform = process.platform;
678
+ let candidates;
679
+ if (platform === "darwin") candidates = [{
680
+ cmd: "open",
681
+ args: [url]
682
+ }];
683
+ else if (platform === "win32") candidates = [{
684
+ cmd: "cmd",
685
+ args: [
686
+ "/c",
687
+ "start",
688
+ "",
689
+ url
690
+ ]
691
+ }];
692
+ else candidates = [
693
+ {
694
+ cmd: "xdg-open",
695
+ args: [url]
696
+ },
697
+ {
698
+ cmd: "sensible-browser",
699
+ args: [url]
700
+ },
701
+ {
702
+ cmd: "x-www-browser",
703
+ args: [url]
704
+ }
705
+ ];
706
+ for (const { cmd, args } of candidates) try {
707
+ const result = spawnSync(cmd, args, {
708
+ encoding: "utf8",
709
+ timeout: 5e3
710
+ });
711
+ if (!result.error && result.status === 0) return true;
712
+ } catch {}
713
+ return false;
714
+ }
715
+ /**
716
+ * Manages auto-opening Chrome DevTools exactly once per relay attach session.
717
+ *
718
+ * Create one instance per `runDebugServer` call and pass its `open()` method
719
+ * as the `onFirstAttach` callback to `startAttachWatcher`.
720
+ *
721
+ * The open fires at most once. Subsequent `open()` calls are no-ops.
722
+ * Opt-out and mock-environment guard are checked at call time.
723
+ */
724
+ var AutoDevtoolsOpener = class {
725
+ _opened = false;
726
+ /**
727
+ * Attempts to auto-open Chrome DevTools.
728
+ *
729
+ * No-op when any of the following conditions hold:
730
+ * 1. Already opened this session (`_opened` is true).
731
+ * 2. `AIT_AUTO_DEVTOOLS=0` opt-out is set.
732
+ * 3. Environment is `mock` (env 1 — F12 is already available).
733
+ * 4. `wssRelayUrl` is null/undefined/empty (tunnel not yet up).
734
+ *
735
+ * Always writes the DevTools URL to stderr so the developer can copy it
736
+ * if the browser open fails or the popup is blocked.
737
+ *
738
+ * @param wssRelayUrl - The public `wss://` relay URL (from tunnel status).
739
+ * @param env - Current MCP environment (`mock` | `relay`).
740
+ */
741
+ open(wssRelayUrl, env) {
742
+ if (this._opened) return;
743
+ if (isAutoDevtoolsDisabled()) return;
744
+ if (env === "mock") return;
745
+ if (!wssRelayUrl) return;
746
+ this._opened = true;
747
+ const devtoolsUrl = buildChromeDevtoolsUrl(wssRelayUrl);
748
+ process.stderr.write(`[ait-debug] 기기가 연결됐습니다 — Chrome DevTools를 자동으로 엽니다.
749
+ [ait-debug] Chrome DevTools URL: ${devtoolsUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)
750
+ `);
751
+ if (!openUrlInBrowser(devtoolsUrl)) process.stderr.write("[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\n");
752
+ }
753
+ /** Returns `true` if `open()` has passed all guards and fired once. */
754
+ get opened() {
755
+ return this._opened;
756
+ }
757
+ };
758
+ //#endregion
759
+ //#region src/mcp/environment.ts
760
+ /**
761
+ * URL patterns that mark a CDP target as a real-device WebView relay.
762
+ *
763
+ * - `intoss-private://` is the Toss in-app private scheme — only ever observed
764
+ * inside the real Toss app WebView.
765
+ * - `*.trycloudflare.com` (host suffix) is the cloudflared quick tunnel used as
766
+ * the relay transport. A target whose URL is on that host is, by construction,
767
+ * reached over the relay.
768
+ *
769
+ * Pattern-only matches — no specific tunnel host or deploymentId is hard-coded.
770
+ */
771
+ const RELAY_URL_PATTERNS = [/^intoss-private:\/\//i, /:\/\/[a-z0-9-]+\.trycloudflare\.com(\/|$|:|\?)/i];
772
+ /**
773
+ * Returns true when the URL string looks like a real-device WebView attached
774
+ * over the Chii relay. Used for `getEnvironment()` precedence step 2.
775
+ */
776
+ function isRelayUrl(url) {
777
+ if (typeof url !== "string" || url.length === 0) return false;
778
+ return RELAY_URL_PATTERNS.some((p) => p.test(url));
779
+ }
780
+ /**
781
+ * Test/override hook — when non-null, `getEnvironment()` returns this value
782
+ * regardless of env vars or connection state. Cleared with `null`.
783
+ */
784
+ let envOverride = null;
785
+ /** Parses the `MCP_ENV` env var into a `McpEnvironment` if valid. */
786
+ function readEnvVar() {
787
+ const raw = process.env.MCP_ENV;
788
+ if (raw === "mock" || raw === "relay") return raw;
789
+ }
790
+ /**
791
+ * Returns the current MCP environment, applying the precedence rules:
792
+ * 1. test override (if set)
793
+ * 2. `MCP_ENV` env var
794
+ * 3. CDP target URL pattern match
795
+ * 4. default `mock`
796
+ */
797
+ function getEnvironment(input = {}) {
798
+ if (envOverride !== null) return envOverride;
799
+ const fromEnv = readEnvVar();
800
+ if (fromEnv !== void 0) return fromEnv;
801
+ const { connection } = input;
802
+ if (connection !== void 0) {
803
+ const targets = connection.listTargets();
804
+ for (const t of targets) if (isRelayUrl(t.url)) return "relay";
805
+ }
806
+ return "mock";
807
+ }
808
+ /**
809
+ * Returns the `EnvironmentReason` that drove the current `getEnvironment()`
810
+ * result. Used by stderr logs and the rejection-reason payload on Tier A/B
811
+ * mismatch errors. SECRET-HANDLING: only stable enum strings — no URL or
812
+ * secret value is ever returned.
813
+ */
814
+ function getEnvironmentReason(input = {}) {
815
+ if (envOverride !== null) return envOverride === "mock" ? "env-var-mock" : "env-var-relay";
816
+ const fromEnv = readEnvVar();
817
+ if (fromEnv === "mock") return "env-var-mock";
818
+ if (fromEnv === "relay") return "env-var-relay";
819
+ const { connection } = input;
820
+ if (connection !== void 0) {
821
+ const targets = connection.listTargets();
822
+ for (const t of targets) if (isRelayUrl(t.url)) return "cdp-target-url-relay-pattern";
823
+ }
824
+ return "default-mock";
825
+ }
826
+ //#endregion
551
827
  //#region src/mcp/local-connection.ts
552
828
  /**
553
829
  * Local-browser `CdpConnection` — attaches directly to a Chromium instance
@@ -1049,6 +1325,145 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl) {
1049
1325
  </html>`;
1050
1326
  }
1051
1327
  //#endregion
1328
+ //#region src/mcp/server-lock.ts
1329
+ /**
1330
+ * Single debug session lock for the `devtools-mcp` debug server.
1331
+ *
1332
+ * At most one debug server process should run on a given machine at a time —
1333
+ * multiple concurrent instances create duplicate cloudflared tunnels, waste
1334
+ * resources, and confuse the user about which wssUrl to use.
1335
+ *
1336
+ * ## Lock file
1337
+ *
1338
+ * Location: `~/.ait-devtools/server.lock`
1339
+ *
1340
+ * Schema (JSON):
1341
+ * ```json
1342
+ * { "pid": 12345, "wssUrl": "wss://xxx.trycloudflare.com", "startedAt": "2026-01-01T00:00:00.000Z" }
1343
+ * ```
1344
+ *
1345
+ * ## Behaviour
1346
+ *
1347
+ * - **Acquire**: write PID + wssUrl + startedAt. Returns a `release()` handle.
1348
+ * - **Stale lock recovery**: if the stored PID is no longer alive
1349
+ * (`process.kill(pid, 0)` throws ESRCH), the lock is silently replaced.
1350
+ * - **Live conflict (option B)**: if the stored PID is alive, `acquireLock`
1351
+ * throws `ServerLockConflictError` with the existing PID and wssUrl so the
1352
+ * caller can surface a clear message to the agent.
1353
+ * - **Release**: remove the lock file. Called on graceful shutdown (SIGINT /
1354
+ * SIGTERM / SIGHUP). SIGKILL survivors leave a stale file — the next startup
1355
+ * recovers it automatically via the alive check.
1356
+ *
1357
+ * ## wssUrl update
1358
+ *
1359
+ * The lock is written before cloudflared starts, so `wssUrl` begins as `null`
1360
+ * and is updated in place once the tunnel URL is known via `updateWssUrl`.
1361
+ *
1362
+ * Node-only.
1363
+ */
1364
+ /** Thrown when a live server process already holds the lock. */
1365
+ var ServerLockConflictError = class extends Error {
1366
+ /** PID of the existing server process. */
1367
+ existingPid;
1368
+ /** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */
1369
+ existingWssUrl;
1370
+ constructor(existingPid, existingWssUrl) {
1371
+ const urlNote = existingWssUrl != null ? ` relay URL: ${existingWssUrl}\n` : " relay URL: (tunnel still starting — retry in a moment)\n";
1372
+ super(`A debug server is already running (PID ${existingPid}).\n` + urlNote + `Stop the existing session before starting a new one.
1373
+ If it is already stopped but this error persists, remove the lock file:
1374
+ rm "${lockFilePath()}"`);
1375
+ this.name = "ServerLockConflictError";
1376
+ this.existingPid = existingPid;
1377
+ this.existingWssUrl = existingWssUrl;
1378
+ }
1379
+ };
1380
+ /** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */
1381
+ function lockFilePath() {
1382
+ return join(process.env.AIT_DEVTOOLS_LOCK_DIR ?? join(homedir(), ".ait-devtools"), "server.lock");
1383
+ }
1384
+ function ensureLockDir(lockPath) {
1385
+ mkdirSync(join(lockPath, ".."), { recursive: true });
1386
+ }
1387
+ /**
1388
+ * Returns `true` when the given PID refers to a running process.
1389
+ *
1390
+ * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process
1391
+ * exists and we have permission to signal it; throws ESRCH when it doesn't exist.
1392
+ */
1393
+ function isPidAlive(pid) {
1394
+ try {
1395
+ process.kill(pid, 0);
1396
+ return true;
1397
+ } catch (err) {
1398
+ if (err.code === "EPERM") return true;
1399
+ return false;
1400
+ }
1401
+ }
1402
+ function readLock(lockPath) {
1403
+ if (!existsSync(lockPath)) return null;
1404
+ try {
1405
+ const raw = readFileSync(lockPath, "utf8");
1406
+ const parsed = JSON.parse(raw);
1407
+ if (typeof parsed === "object" && parsed !== null && "pid" in parsed && typeof parsed.pid === "number" && "startedAt" in parsed && typeof parsed.startedAt === "string") {
1408
+ const p = parsed;
1409
+ return {
1410
+ pid: p.pid,
1411
+ wssUrl: typeof p.wssUrl === "string" ? p.wssUrl : null,
1412
+ startedAt: p.startedAt
1413
+ };
1414
+ }
1415
+ return null;
1416
+ } catch {
1417
+ return null;
1418
+ }
1419
+ }
1420
+ function writeLock(lockPath, data) {
1421
+ ensureLockDir(lockPath);
1422
+ writeFileSync(lockPath, JSON.stringify(data, null, 2), { encoding: "utf8" });
1423
+ }
1424
+ function removeLock(lockPath) {
1425
+ try {
1426
+ rmSync(lockPath);
1427
+ } catch {}
1428
+ }
1429
+ /**
1430
+ * Attempts to acquire the server lock.
1431
+ *
1432
+ * - If no lock exists (or the lock is stale): writes a new lock and returns a
1433
+ * `LockHandle` with `updateWssUrl` + `release`.
1434
+ * - If a live process holds the lock: throws `ServerLockConflictError`.
1435
+ *
1436
+ * The initial `wssUrl` in the lock file is `null` — call
1437
+ * `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.
1438
+ */
1439
+ function acquireLock() {
1440
+ const lockPath = lockFilePath();
1441
+ const existing = readLock(lockPath);
1442
+ if (existing !== null) {
1443
+ if (isPidAlive(existing.pid)) throw new ServerLockConflictError(existing.pid, existing.wssUrl);
1444
+ process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
1445
+ }
1446
+ const data = {
1447
+ pid: process.pid,
1448
+ wssUrl: null,
1449
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1450
+ };
1451
+ writeLock(lockPath, data);
1452
+ let released = false;
1453
+ return {
1454
+ updateWssUrl(wssUrl) {
1455
+ if (released) return;
1456
+ data.wssUrl = wssUrl;
1457
+ writeLock(lockPath, data);
1458
+ },
1459
+ release() {
1460
+ if (released) return;
1461
+ released = true;
1462
+ removeLock(lockPath);
1463
+ }
1464
+ };
1465
+ }
1466
+ //#endregion
1052
1467
  //#region src/mcp/deeplink.ts
1053
1468
  /**
1054
1469
  * Build a self-attaching dogfood deep link.
@@ -1327,7 +1742,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1327
1742
  type: "object",
1328
1743
  properties: {},
1329
1744
  required: []
1330
- }
1745
+ },
1746
+ availableIn: "both"
1331
1747
  },
1332
1748
  {
1333
1749
  name: "list_network_requests",
@@ -1336,7 +1752,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1336
1752
  type: "object",
1337
1753
  properties: {},
1338
1754
  required: []
1339
- }
1755
+ },
1756
+ availableIn: "both"
1340
1757
  },
1341
1758
  {
1342
1759
  name: "list_pages",
@@ -1345,7 +1762,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1345
1762
  type: "object",
1346
1763
  properties: {},
1347
1764
  required: []
1348
- }
1765
+ },
1766
+ availableIn: "both"
1349
1767
  },
1350
1768
  {
1351
1769
  name: "build_attach_url",
@@ -1367,7 +1785,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1367
1785
  }
1368
1786
  },
1369
1787
  required: ["scheme_url"]
1370
- }
1788
+ },
1789
+ availableIn: "relay"
1371
1790
  },
1372
1791
  {
1373
1792
  name: "get_dom_document",
@@ -1376,7 +1795,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1376
1795
  type: "object",
1377
1796
  properties: {},
1378
1797
  required: []
1379
- }
1798
+ },
1799
+ availableIn: "both"
1380
1800
  },
1381
1801
  {
1382
1802
  name: "take_snapshot",
@@ -1385,7 +1805,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1385
1805
  type: "object",
1386
1806
  properties: {},
1387
1807
  required: []
1388
- }
1808
+ },
1809
+ availableIn: "both"
1389
1810
  },
1390
1811
  {
1391
1812
  name: "take_screenshot",
@@ -1394,16 +1815,18 @@ const DEBUG_TOOL_DEFINITIONS = [
1394
1815
  type: "object",
1395
1816
  properties: {},
1396
1817
  required: []
1397
- }
1818
+ },
1819
+ availableIn: "both"
1398
1820
  },
1399
1821
  {
1400
1822
  name: "measure_safe_area",
1401
- description: "Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. Read-only — does not modify page state. Use in a relay session (phone attached) to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires the relay to be attached — call list_pages first.",
1823
+ description: "Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. Read-only — does not modify page state. Tier C per RFC #277: the same Runtime.evaluate probe runs in both `mock` (devtools panel page with window.__ait state) and `relay` (real-device WebView with window.__sdk). The result includes a `source: \"mock\" | \"relay\"` field so consumers can identify provenance without inspecting payload values. Use in a relay session (phone attached) to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires a page to be attached — call list_pages first.",
1402
1824
  inputSchema: {
1403
1825
  type: "object",
1404
1826
  properties: {},
1405
1827
  required: []
1406
- }
1828
+ },
1829
+ availableIn: "both"
1407
1830
  },
1408
1831
  {
1409
1832
  name: "evaluate",
@@ -1415,7 +1838,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1415
1838
  description: "JavaScript expression to evaluate in the page context."
1416
1839
  } },
1417
1840
  required: ["expression"]
1418
- }
1841
+ },
1842
+ availableIn: "both"
1419
1843
  },
1420
1844
  {
1421
1845
  name: "list_exceptions",
@@ -1427,7 +1851,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1427
1851
  description: "Maximum number of exceptions to return (default 50, max 50)."
1428
1852
  } },
1429
1853
  required: []
1430
- }
1854
+ },
1855
+ availableIn: "both"
1431
1856
  },
1432
1857
  {
1433
1858
  name: "call_sdk",
@@ -1446,7 +1871,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1446
1871
  }
1447
1872
  },
1448
1873
  required: ["name"]
1449
- }
1874
+ },
1875
+ availableIn: "both"
1450
1876
  },
1451
1877
  {
1452
1878
  name: "AIT.getSdkCallHistory",
@@ -1455,7 +1881,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1455
1881
  type: "object",
1456
1882
  properties: {},
1457
1883
  required: []
1458
- }
1884
+ },
1885
+ availableIn: "both"
1459
1886
  },
1460
1887
  {
1461
1888
  name: "AIT.getMockState",
@@ -1464,7 +1891,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1464
1891
  type: "object",
1465
1892
  properties: {},
1466
1893
  required: []
1467
- }
1894
+ },
1895
+ availableIn: "both"
1468
1896
  },
1469
1897
  {
1470
1898
  name: "AIT.getOperationalEnvironment",
@@ -1473,7 +1901,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1473
1901
  type: "object",
1474
1902
  properties: {},
1475
1903
  required: []
1476
- }
1904
+ },
1905
+ availableIn: "both"
1477
1906
  }
1478
1907
  ];
1479
1908
  const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
@@ -1481,6 +1910,34 @@ function isDebugToolName(name) {
1481
1910
  return DEBUG_TOOL_NAMES.has(name);
1482
1911
  }
1483
1912
  /**
1913
+ * Returns the `ToolAvailability` declared on a registered debug tool, or
1914
+ * `undefined` when the name is not a known debug tool. Used by the tool
1915
+ * registry to filter `tools/list` by current env and by the call handler to
1916
+ * reject env-mismatch invocations.
1917
+ */
1918
+ function getToolAvailability(name) {
1919
+ for (const t of DEBUG_TOOL_DEFINITIONS) if (t.name === name) return t.availableIn;
1920
+ }
1921
+ /**
1922
+ * Returns true when the named tool is available in the given environment.
1923
+ * Unknown tools return `false` — callers should reject them as unknown rather
1924
+ * than as env-mismatched.
1925
+ */
1926
+ function isToolAvailableIn(name, env) {
1927
+ const availability = getToolAvailability(name);
1928
+ if (availability === void 0) return false;
1929
+ if (availability === "both") return true;
1930
+ return availability === env;
1931
+ }
1932
+ /**
1933
+ * Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`
1934
+ * matches the given env. Pure — preserves order; both Tier C ("both") and the
1935
+ * matching single-env tier pass through.
1936
+ */
1937
+ function filterToolsByEnvironment(tools, env) {
1938
+ return tools.filter((t) => t.availableIn === "both" || t.availableIn === env);
1939
+ }
1940
+ /**
1484
1941
  * Tool names that are available before any page attaches (bootstrap tier).
1485
1942
  *
1486
1943
  * `build_attach_url` — pure URL synthesis, no attach needed.
@@ -1781,11 +2238,13 @@ async function takeScreenshot(connection) {
1781
2238
  * The JS probe injected via `Runtime.evaluate`. It reads:
1782
2239
  * 1. `env(safe-area-inset-*)` via a temporary element with padding set to
1783
2240
  * those CSS env vars, then `getComputedStyle`.
1784
- * 2. `window.__sdk.SafeAreaInsets.get()` (1st priority) or
1785
- * `window.__sdk.getSafeAreaInsets()` (2nd priority) both surfaces
1786
- * confirmed live on iPhone 15 Pro relay. `window.__sdk` is only present
1787
- * in dogfood (__DEBUG_BUILD__) bundles; outside those it is undefined.
1788
- * If both paths fail the result carries `sdkInsetsError` explaining why.
2241
+ * 2. SDK insets via a priority chain so the SAME probe works on both relay
2242
+ * (real device) and mock (devtools panel page):
2243
+ * a. `window.__sdk.SafeAreaInsets.get()` dogfood bundle on real device.
2244
+ * b. `window.__sdk.getSafeAreaInsets()` — dogfood bundle (deprecated).
2245
+ * c. `window.__ait.state.safeAreaInsets` — devtools mock state (mock env).
2246
+ * The probe records `sdkInsetsSource` = `'window.__sdk'` | `'window.__ait'`
2247
+ * | `null`. If all paths fail the result carries `sdkInsetsError`.
1789
2248
  * 3. nav bar geometry: the SDK does not expose navBar height as a standalone
1790
2249
  * API — `.ait-navbar` DOM height is read as a cross-check, and
1791
2250
  * `navBarHeightSource` records where it came from.
@@ -1793,9 +2252,15 @@ async function takeScreenshot(connection) {
1793
2252
  *
1794
2253
  * Returns a plain JSON-serialisable object so `returnByValue: true` works.
1795
2254
  *
1796
- * NOTE: This expression is evaluated in the page context on the real device.
1797
- * It does not mutate any page state — the temporary element is removed after
1798
- * reading. No secret or auth token is read or returned.
2255
+ * NOTE: This expression is evaluated in the page context on the real device
2256
+ * (relay) or on the mock panel page. It does not mutate any page state — the
2257
+ * temporary element is removed after reading. No secret or auth token is read
2258
+ * or returned.
2259
+ *
2260
+ * RFC #277 Tier C parity: the SAME probe string runs in both envs. Mock fidelity
2261
+ * comes from the panel's `applyViewport` / `computeSafeAreaInsets` correctly
2262
+ * setting `window.__ait.state.safeAreaInsets` (#275). When that is correct,
2263
+ * the cssEnv + sdkInsets pair returned here matches the relay's shape.
1799
2264
  */
1800
2265
  const SAFE_AREA_PROBE_EXPRESSION = `
1801
2266
  (function() {
@@ -1815,17 +2280,28 @@ const SAFE_AREA_PROBE_EXPRESSION = `
1815
2280
  };
1816
2281
  document.documentElement.removeChild(el);
1817
2282
  var sdkInsets = null;
2283
+ var sdkInsetsSource = null;
1818
2284
  var sdkInsetsError = undefined;
1819
2285
  try {
1820
2286
  var sdk = window.__sdk;
2287
+ var ait = window.__ait;
1821
2288
  if (sdk && sdk.SafeAreaInsets && typeof sdk.SafeAreaInsets.get === 'function') {
1822
2289
  sdkInsets = sdk.SafeAreaInsets.get();
2290
+ sdkInsetsSource = 'window.__sdk';
1823
2291
  } else if (sdk && typeof sdk.getSafeAreaInsets === 'function') {
1824
2292
  sdkInsets = sdk.getSafeAreaInsets();
1825
- } else if (!sdk) {
1826
- sdkInsetsError = 'window.__sdk not available (non-dogfood bundle)';
1827
- } else {
2293
+ sdkInsetsSource = 'window.__sdk';
2294
+ } else if (ait && ait.state && ait.state.safeAreaInsets &&
2295
+ typeof ait.state.safeAreaInsets.top === 'number') {
2296
+ var s = ait.state.safeAreaInsets;
2297
+ sdkInsets = { top: s.top, bottom: s.bottom, left: s.left, right: s.right };
2298
+ sdkInsetsSource = 'window.__ait';
2299
+ } else if (!sdk && !ait) {
2300
+ sdkInsetsError = 'neither window.__sdk (relay) nor window.__ait (mock) available';
2301
+ } else if (sdk) {
1828
2302
  sdkInsetsError = 'neither SafeAreaInsets.get nor getSafeAreaInsets found on window.__sdk';
2303
+ } else {
2304
+ sdkInsetsError = 'window.__ait.state.safeAreaInsets is missing or malformed';
1829
2305
  }
1830
2306
  } catch(e) {
1831
2307
  sdkInsetsError = String(e && e.message || e);
@@ -1842,6 +2318,7 @@ const SAFE_AREA_PROBE_EXPRESSION = `
1842
2318
  var result = {
1843
2319
  cssEnv: cssEnv,
1844
2320
  sdkInsets: sdkInsets,
2321
+ sdkInsetsSource: sdkInsetsSource,
1845
2322
  navBarHeight: navBarHeight,
1846
2323
  navBarHeightSource: navBarHeightSource,
1847
2324
  innerWidth: window.innerWidth,
@@ -1858,9 +2335,11 @@ const SAFE_AREA_PROBE_EXPRESSION = `
1858
2335
  * The probe returns a JSON string (because `returnByValue:true` with a plain
1859
2336
  * object works unreliably across Chii relay versions — stringifying is safer).
1860
2337
  *
2338
+ * `source` is supplied by the caller (`measureSafeArea`) from the env SSoT.
2339
+ *
1861
2340
  * Throws if the result is missing, contains an exception, or cannot be parsed.
1862
2341
  */
1863
- function normalizeSafeAreaResult(rawValue) {
2342
+ function normalizeSafeAreaResult(rawValue, source) {
1864
2343
  if (typeof rawValue !== "string") throw new Error(`measure_safe_area: probe returned unexpected type "${typeof rawValue}" — expected JSON string`);
1865
2344
  let parsed;
1866
2345
  try {
@@ -1889,6 +2368,7 @@ function normalizeSafeAreaResult(rawValue) {
1889
2368
  left: 0
1890
2369
  };
1891
2370
  const sdkInsets = requireInsets("sdkInsets");
2371
+ const sdkInsetsSource = obj.sdkInsetsSource === "window.__sdk" || obj.sdkInsetsSource === "window.__ait" ? obj.sdkInsetsSource : null;
1892
2372
  const sdkInsetsError = typeof obj.sdkInsetsError === "string" ? obj.sdkInsetsError : void 0;
1893
2373
  const navBarHeight = typeof obj.navBarHeight === "number" ? obj.navBarHeight : null;
1894
2374
  const navBarHeightSource = typeof obj.navBarHeightSource === "string" ? obj.navBarHeightSource : "not-exposed-by-sdk";
@@ -1897,8 +2377,10 @@ function normalizeSafeAreaResult(rawValue) {
1897
2377
  const devicePixelRatio = typeof obj.devicePixelRatio === "number" ? obj.devicePixelRatio : 1;
1898
2378
  const userAgent = typeof obj.userAgent === "string" ? obj.userAgent : "";
1899
2379
  return {
2380
+ source,
1900
2381
  cssEnv,
1901
2382
  sdkInsets,
2383
+ sdkInsetsSource,
1902
2384
  ...sdkInsetsError !== void 0 ? { sdkInsetsError } : {},
1903
2385
  navBarHeight,
1904
2386
  navBarHeightSource,
@@ -1912,9 +2394,16 @@ function normalizeSafeAreaResult(rawValue) {
1912
2394
  * Runs the safe-area probe on the attached page and returns a normalized
1913
2395
  * `SafeAreaMeasurement`. Read-only — does not mutate page state.
1914
2396
  *
2397
+ * `source` is supplied by the caller from the env detection SSoT (see
2398
+ * `src/mcp/environment.ts`). The same `Runtime.evaluate` call runs in both
2399
+ * envs — the probe expression tries `window.__sdk` first (relay) then
2400
+ * `window.__ait` (mock), so mock fidelity is enforced by the panel's
2401
+ * `applyViewport`/`computeSafeAreaInsets` keeping `__ait.state.safeAreaInsets`
2402
+ * correct (RFC #277 Tier C parity, #275 model).
2403
+ *
1915
2404
  * Throws on CDP error, probe exception, or result parse failure.
1916
2405
  */
1917
- async function measureSafeArea(connection) {
2406
+ async function measureSafeArea(connection, source) {
1918
2407
  const result = await connection.send("Runtime.evaluate", {
1919
2408
  expression: SAFE_AREA_PROBE_EXPRESSION,
1920
2409
  returnByValue: true,
@@ -1924,7 +2413,7 @@ async function measureSafeArea(connection) {
1924
2413
  const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
1925
2414
  throw new Error(`measure_safe_area: probe threw — ${msg}`);
1926
2415
  }
1927
- return normalizeSafeAreaResult(result.result.value);
2416
+ return normalizeSafeAreaResult(result.result.value, source);
1928
2417
  }
1929
2418
  /**
1930
2419
  * Evaluates an arbitrary JS expression on the attached page via
@@ -2339,6 +2828,65 @@ async function printAttachBanner(input) {
2339
2828
  * Node-only.
2340
2829
  */
2341
2830
  /**
2831
+ * Parses `_deploymentId` from the query string of a scheme URL.
2832
+ *
2833
+ * Returns `null` when the param is absent or empty — callers treat that as
2834
+ * "no deploymentId filter; match on presence only" and fall back to the
2835
+ * original `attachedPages.length > 0` condition.
2836
+ *
2837
+ * SECRET-HANDLING: deploymentId is a public identifier and may appear in
2838
+ * debug output. Never confuse it with TOTP secrets or relay tunnel URLs.
2839
+ */
2840
+ function extractDeploymentId(schemeUrl) {
2841
+ try {
2842
+ const qIndex = schemeUrl.indexOf("?");
2843
+ if (qIndex === -1) return null;
2844
+ const id = new URLSearchParams(schemeUrl.slice(qIndex + 1)).get("_deploymentId");
2845
+ return id && id.length > 0 ? id : null;
2846
+ } catch {
2847
+ return null;
2848
+ }
2849
+ }
2850
+ /**
2851
+ * Waits for the first target matching `filterFn` to attach, using the
2852
+ * event-driven `waitForFirstTarget()` on `ChiiCdpConnection` instances, or
2853
+ * falling back to a polling loop for generic `CdpConnection` fakes (tests).
2854
+ *
2855
+ * This eliminates the polling-only race that previously caused `wait_for_attach`
2856
+ * to resolve before the relay had observed the first inbound CDP message from
2857
+ * the phone.
2858
+ *
2859
+ * @param connection - The CDP connection (production or fake).
2860
+ * @param filterFn - Resolves when this predicate is satisfied.
2861
+ * @param timeoutMs - Maximum wait time in ms.
2862
+ * @param pollIntervalMs - Fallback poll interval for non-ChiiCdpConnection.
2863
+ */
2864
+ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs = 1e3) {
2865
+ if (connection instanceof ChiiCdpConnection) return connection.waitForFirstTarget(filterFn, timeoutMs, pollIntervalMs);
2866
+ return new Promise((resolve, reject) => {
2867
+ const deadline = Date.now() + timeoutMs;
2868
+ let settled = false;
2869
+ const poll = setInterval(() => {
2870
+ const targets = connection.listTargets();
2871
+ if (filterFn(targets)) {
2872
+ settled = true;
2873
+ clearInterval(poll);
2874
+ resolve(targets);
2875
+ } else if (Date.now() >= deadline) {
2876
+ settled = true;
2877
+ clearInterval(poll);
2878
+ reject(/* @__PURE__ */ new Error(`waitForAttachWithEvents: 타임아웃 (${timeoutMs}ms)`));
2879
+ }
2880
+ }, pollIntervalMs);
2881
+ const targets = connection.listTargets();
2882
+ if (!settled && filterFn(targets)) {
2883
+ settled = true;
2884
+ clearInterval(poll);
2885
+ resolve(targets);
2886
+ }
2887
+ });
2888
+ }
2889
+ /**
2342
2890
  * Builds the debug-mode MCP server around an injected CDP connection + AIT
2343
2891
  * source + tunnel status getter. Pure wiring — does not start a relay or
2344
2892
  * tunnel, which is what makes the tool surface unit-testable.
@@ -2351,13 +2899,18 @@ async function printAttachBanner(input) {
2351
2899
  * naturally via `enableDomains`). The tier only controls visibility.
2352
2900
  */
2353
2901
  function createDebugServer(deps) {
2354
- const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer } = deps;
2902
+ const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep } = deps;
2903
+ const resolveEnvironment = getEnvDep ?? (() => getEnvironment({ connection }));
2904
+ const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({ connection }));
2355
2905
  const server = new Server({
2356
2906
  name: "ait-debug",
2357
- version: "0.1.41"
2907
+ version: "0.1.43"
2358
2908
  }, { capabilities: { tools: { listChanged: true } } });
2359
2909
  server.setRequestHandler(ListToolsRequestSchema, () => {
2360
- return { tools: connection.listTargets().length > 0 ? DEBUG_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) : DEBUG_TOOL_DEFINITIONS.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
2910
+ const env = resolveEnvironment();
2911
+ const attached = connection.listTargets().length > 0;
2912
+ const envFiltered = filterToolsByEnvironment(DEBUG_TOOL_DEFINITIONS, env);
2913
+ return { tools: attached ? envFiltered.map((tool) => ({ ...tool })) : envFiltered.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
2361
2914
  });
2362
2915
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2363
2916
  const name = request.params.name;
@@ -2368,6 +2921,18 @@ function createDebugServer(deps) {
2368
2921
  }],
2369
2922
  isError: true
2370
2923
  };
2924
+ const env = resolveEnvironment();
2925
+ if (!isToolAvailableIn(name, env)) {
2926
+ const reason = `tool ${name} is available only in ${getToolAvailability(name)}. Current environment is ${env} (${resolveEnvironmentReason()}).`;
2927
+ process.stderr.write(`[ait-debug] tier-filter rejected ${name}: ${reason}\n`);
2928
+ return {
2929
+ content: [{
2930
+ type: "text",
2931
+ text: reason
2932
+ }],
2933
+ isError: true
2934
+ };
2935
+ }
2371
2936
  if (isAitToolName(name)) try {
2372
2937
  await connection.enableDomains();
2373
2938
  switch (name) {
@@ -2390,6 +2955,20 @@ function createDebugServer(deps) {
2390
2955
  };
2391
2956
  const waitForAttach = request.params.arguments?.wait_for_attach === true;
2392
2957
  const openInBrowser = request.params.arguments?.open_in_browser !== false;
2958
+ const deploymentId = extractDeploymentId(schemeUrl);
2959
+ if (!deploymentId) process.stderr.write("[ait-debug] build_attach_url: no _deploymentId in scheme_url; matching on presence only\n");
2960
+ /** Returns true when the page list satisfies the attach condition. */
2961
+ const isMatchingPage = (pages) => {
2962
+ if (pages.length === 0) return false;
2963
+ if (deploymentId === null) return true;
2964
+ return pages.some((p) => p.url.includes(deploymentId));
2965
+ };
2966
+ /** Builds a timeout error message with diagnostic context. */
2967
+ const buildTimeoutError = (baseText, timeoutSec, observed) => {
2968
+ const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
2969
+ const observedNote = observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : "";
2970
+ return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
2971
+ };
2393
2972
  try {
2394
2973
  const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
2395
2974
  const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
@@ -2402,22 +2981,19 @@ function createDebugServer(deps) {
2402
2981
  type: "text",
2403
2982
  text: shortText
2404
2983
  }] };
2405
- const POLL_INTERVAL_MS = 1e3;
2406
- const TIMEOUT_MS = waitForAttachTimeoutMs;
2407
- const deadline = Date.now() + TIMEOUT_MS;
2408
2984
  let attachedPages = [];
2409
- while (Date.now() < deadline) {
2985
+ try {
2986
+ attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
2987
+ } catch {
2410
2988
  attachedPages = connection.listTargets();
2411
- if (attachedPages.length > 0) break;
2412
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
2989
+ return {
2990
+ content: [{
2991
+ type: "text",
2992
+ text: buildTimeoutError(shortText, waitForAttachTimeoutMs / 1e3, attachedPages)
2993
+ }],
2994
+ isError: true
2995
+ };
2413
2996
  }
2414
- if (attachedPages.length === 0) return {
2415
- content: [{
2416
- type: "text",
2417
- text: `${shortText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
2418
- }],
2419
- isError: true
2420
- };
2421
2997
  const pagesResult = listPages(connection, getTunnelStatus());
2422
2998
  return { content: [{
2423
2999
  type: "text",
@@ -2435,22 +3011,19 @@ function createDebugServer(deps) {
2435
3011
  type: "text",
2436
3012
  text: baseText
2437
3013
  }] };
2438
- const POLL_INTERVAL_MS_FB = 1e3;
2439
- const TIMEOUT_MS_FB = waitForAttachTimeoutMs;
2440
- const deadline2 = Date.now() + TIMEOUT_MS_FB;
2441
3014
  let attachedPagesFb = [];
2442
- while (Date.now() < deadline2) {
3015
+ try {
3016
+ attachedPagesFb = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3017
+ } catch {
2443
3018
  attachedPagesFb = connection.listTargets();
2444
- if (attachedPagesFb.length > 0) break;
2445
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS_FB));
3019
+ return {
3020
+ content: [{
3021
+ type: "text",
3022
+ text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPagesFb)
3023
+ }],
3024
+ isError: true
3025
+ };
2446
3026
  }
2447
- if (attachedPagesFb.length === 0) return {
2448
- content: [{
2449
- type: "text",
2450
- text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS_FB / 1e3}s — call list_pages to retry.`
2451
- }],
2452
- isError: true
2453
- };
2454
3027
  const pagesResultFb = listPages(connection, getTunnelStatus());
2455
3028
  return { content: [{
2456
3029
  type: "text",
@@ -2466,22 +3039,19 @@ function createDebugServer(deps) {
2466
3039
  type: "text",
2467
3040
  text: baseText
2468
3041
  }] };
2469
- const POLL_INTERVAL_MS = 1e3;
2470
- const TIMEOUT_MS = waitForAttachTimeoutMs;
2471
- const deadline = Date.now() + TIMEOUT_MS;
2472
3042
  let attachedPages = [];
2473
- while (Date.now() < deadline) {
3043
+ try {
3044
+ attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3045
+ } catch {
2474
3046
  attachedPages = connection.listTargets();
2475
- if (attachedPages.length > 0) break;
2476
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
3047
+ return {
3048
+ content: [{
3049
+ type: "text",
3050
+ text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPages)
3051
+ }],
3052
+ isError: true
3053
+ };
2477
3054
  }
2478
- if (attachedPages.length === 0) return {
2479
- content: [{
2480
- type: "text",
2481
- text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
2482
- }],
2483
- isError: true
2484
- };
2485
3055
  const pagesResult = listPages(connection, getTunnelStatus());
2486
3056
  return { content: [{
2487
3057
  type: "text",
@@ -2495,7 +3065,12 @@ function createDebugServer(deps) {
2495
3065
  await connection.enableDomains();
2496
3066
  } catch (err) {
2497
3067
  const message = err instanceof Error ? err.message : String(err);
2498
- if (name === "list_pages") return jsonResult$1(listPages(connection, getTunnelStatus()));
3068
+ if (name === "list_pages") {
3069
+ if (connection instanceof ChiiCdpConnection) try {
3070
+ await connection.refreshTargets();
3071
+ } catch {}
3072
+ return jsonResult$1(listPages(connection, getTunnelStatus()));
3073
+ }
2499
3074
  return {
2500
3075
  content: [{
2501
3076
  type: "text",
@@ -2512,7 +3087,11 @@ function createDebugServer(deps) {
2512
3087
  return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
2513
3088
  }
2514
3089
  case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
2515
- case "list_pages": return jsonResult$1(listPages(connection, getTunnelStatus()));
3090
+ case "list_pages":
3091
+ if (connection instanceof ChiiCdpConnection) try {
3092
+ await connection.refreshTargets();
3093
+ } catch {}
3094
+ return jsonResult$1(listPages(connection, getTunnelStatus()));
2516
3095
  case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
2517
3096
  case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
2518
3097
  case "take_screenshot": {
@@ -2523,7 +3102,7 @@ function createDebugServer(deps) {
2523
3102
  mimeType: shot.mimeType
2524
3103
  }] };
2525
3104
  }
2526
- case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection));
3105
+ case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection, resolveEnvironment()));
2527
3106
  case "evaluate": {
2528
3107
  const expression = request.params.arguments?.expression;
2529
3108
  if (typeof expression !== "string" || expression === "") return {
@@ -2570,11 +3149,22 @@ function unknownTool(name) {
2570
3149
  isError: true
2571
3150
  };
2572
3151
  }
3152
+ /**
3153
+ * Detects whether an error is a relay/websocket disconnect error.
3154
+ * These are distinguished from "no page attached yet" errors because they
3155
+ * require enableDomains() to be called again (re-establish the websocket),
3156
+ * not just waiting for a target to appear.
3157
+ */
3158
+ function isDisconnectError(err) {
3159
+ if (!(err instanceof Error)) return false;
3160
+ const msg = err.message;
3161
+ return msg.includes("relay에 연결되어 있지 않습니다") || msg.includes("relay WebSocket") || msg.includes("replaced-by-new-attach") || msg.includes("Chii relay connection closed");
3162
+ }
2573
3163
  function errorResult(err, name) {
2574
3164
  return {
2575
3165
  content: [{
2576
3166
  type: "text",
2577
- text: `${name} failed: ${err instanceof Error ? err.message : String(err)}\nCall list_pages to confirm a mini-app has attached over the relay.`
3167
+ text: `${name} failed: ${err instanceof Error ? err.message : String(err)}${isDisconnectError(err) ? "\n\nrelay 연결이 끊겼습니다. list_pages → enableDomains() 재호출로 재연결하세요. 폰이 백그라운드로 내려갔거나 미니앱이 종료됐을 수 있습니다." : "\nCall list_pages to confirm a mini-app has attached over the relay."}`
2578
3168
  }],
2579
3169
  isError: true
2580
3170
  };
@@ -2588,19 +3178,28 @@ function errorResult(err, name) {
2588
3178
  * `server.sendToolListChanged()` exactly once — on the first transition — then
2589
3179
  * clears itself. Shutdown calls `stop()` to clear the interval.
2590
3180
  *
3181
+ * `onFirstAttach` is called once on the 0→N transition (or immediately when
3182
+ * already attached). Use this to trigger side-effects such as auto-opening
3183
+ * Chrome DevTools (issue #282). The callback is optional; omitting it preserves
3184
+ * the previous behaviour exactly.
3185
+ *
2591
3186
  * SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
2592
3187
  * Only an attach-detected stderr line is emitted (no target details).
2593
3188
  *
2594
3189
  * @returns `stop` — call this during shutdown to clear the interval.
2595
3190
  */
2596
- function startAttachWatcher(connection, server, intervalMs = 1e3) {
3191
+ function startAttachWatcher(connection, server, intervalMs = 1e3, onFirstAttach) {
2597
3192
  let wasAttached = connection.listTargets().length > 0;
2598
- if (wasAttached) server.sendToolListChanged();
3193
+ if (wasAttached) {
3194
+ server.sendToolListChanged();
3195
+ onFirstAttach?.();
3196
+ }
2599
3197
  const handle = setInterval(() => {
2600
3198
  const isAttached = connection.listTargets().length > 0;
2601
3199
  if (!wasAttached && isAttached) {
2602
3200
  wasAttached = true;
2603
3201
  server.sendToolListChanged();
3202
+ onFirstAttach?.();
2604
3203
  clearInterval(handle);
2605
3204
  }
2606
3205
  }, intervalMs);
@@ -2640,6 +3239,7 @@ function buildRelayVerifyAuth() {
2640
3239
  * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
2641
3240
  */
2642
3241
  async function runDebugServer(options = {}) {
3242
+ const lockHandle = acquireLock();
2643
3243
  const relayPort = options.relayPort ?? 0;
2644
3244
  const verifyAuth = buildRelayVerifyAuth();
2645
3245
  const totpEnabled = verifyAuth !== void 0;
@@ -2659,6 +3259,7 @@ async function runDebugServer(options = {}) {
2659
3259
  up: true,
2660
3260
  wssUrl: t.wssUrl
2661
3261
  };
3262
+ lockHandle.updateWssUrl(t.wssUrl);
2662
3263
  return printAttachBanner({
2663
3264
  wssUrl: t.wssUrl,
2664
3265
  totpEnabled
@@ -2677,6 +3278,7 @@ async function runDebugServer(options = {}) {
2677
3278
  const message = err instanceof Error ? err.message : String(err);
2678
3279
  process.stderr.write(`[ait-debug] QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}\n`);
2679
3280
  }
3281
+ const devtoolsOpener = new AutoDevtoolsOpener();
2680
3282
  const server = createDebugServer({
2681
3283
  connection,
2682
3284
  aitSource,
@@ -2697,6 +3299,7 @@ async function runDebugServer(options = {}) {
2697
3299
  relay.close();
2698
3300
  server.close();
2699
3301
  qrServer?.close();
3302
+ lockHandle.release();
2700
3303
  };
2701
3304
  process.once("SIGINT", shutdown);
2702
3305
  process.once("SIGTERM", shutdown);
@@ -2706,6 +3309,7 @@ async function runDebugServer(options = {}) {
2706
3309
  closed = true;
2707
3310
  attachWatcher?.stop();
2708
3311
  tunnel?.stop();
3312
+ lockHandle.release();
2709
3313
  }
2710
3314
  });
2711
3315
  process.on("uncaughtException", (err) => {
@@ -2719,7 +3323,9 @@ async function runDebugServer(options = {}) {
2719
3323
  process.exit(1);
2720
3324
  });
2721
3325
  await server.connect(transport);
2722
- attachWatcher = startAttachWatcher(connection, server);
3326
+ attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
3327
+ devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({ connection }));
3328
+ });
2723
3329
  }
2724
3330
  /**
2725
3331
  * Boots the local-browser debug stack and serves it over stdio:
@@ -2740,6 +3346,7 @@ async function runDebugServer(options = {}) {
2740
3346
  * expected and noted in the PR as an explicit out-of-scope follow-up.
2741
3347
  */
2742
3348
  async function runLocalDebugServer(options = {}) {
3349
+ const lockHandle = acquireLock();
2743
3350
  const chromium = await launchChromium({
2744
3351
  port: options.cdpPort ?? 0,
2745
3352
  devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
@@ -2766,6 +3373,7 @@ async function runLocalDebugServer(options = {}) {
2766
3373
  connection.close();
2767
3374
  chromium.stop();
2768
3375
  server.close();
3376
+ lockHandle.release();
2769
3377
  };
2770
3378
  process.once("SIGINT", shutdown);
2771
3379
  process.once("SIGTERM", shutdown);
@@ -2775,6 +3383,7 @@ async function runLocalDebugServer(options = {}) {
2775
3383
  closed = true;
2776
3384
  attachWatcher?.stop();
2777
3385
  chromium.stop();
3386
+ lockHandle.release();
2778
3387
  }
2779
3388
  });
2780
3389
  process.on("uncaughtException", (err) => {
@@ -2863,7 +3472,13 @@ var HttpAitSource = class {
2863
3472
  * }
2864
3473
  * }
2865
3474
  */
2866
- /** Tool descriptors served by the dev-mode server. */
3475
+ /**
3476
+ * Tool descriptors served by the dev-mode server.
3477
+ *
3478
+ * All dev-mode tools are Tier C (both envs) per RFC #277 — the dev-mode server
3479
+ * itself is the mock-side embodiment of those Tier C tools. `availableIn` is
3480
+ * declared so the surface stays consistent with the debug-mode registry.
3481
+ */
2867
3482
  const DEV_TOOL_DEFINITIONS = [
2868
3483
  {
2869
3484
  name: "AIT.getMockState",
@@ -2872,7 +3487,8 @@ const DEV_TOOL_DEFINITIONS = [
2872
3487
  type: "object",
2873
3488
  properties: {},
2874
3489
  required: []
2875
- }
3490
+ },
3491
+ availableIn: "both"
2876
3492
  },
2877
3493
  {
2878
3494
  name: "AIT.getOperationalEnvironment",
@@ -2881,7 +3497,8 @@ const DEV_TOOL_DEFINITIONS = [
2881
3497
  type: "object",
2882
3498
  properties: {},
2883
3499
  required: []
2884
- }
3500
+ },
3501
+ availableIn: "both"
2885
3502
  },
2886
3503
  {
2887
3504
  name: "AIT.getSdkCallHistory",
@@ -2890,7 +3507,8 @@ const DEV_TOOL_DEFINITIONS = [
2890
3507
  type: "object",
2891
3508
  properties: {},
2892
3509
  required: []
2893
- }
3510
+ },
3511
+ availableIn: "both"
2894
3512
  },
2895
3513
  {
2896
3514
  name: "devtools_get_mock_state",
@@ -2899,7 +3517,8 @@ const DEV_TOOL_DEFINITIONS = [
2899
3517
  type: "object",
2900
3518
  properties: {},
2901
3519
  required: []
2902
- }
3520
+ },
3521
+ availableIn: "both"
2903
3522
  }
2904
3523
  ];
2905
3524
  const DEV_TOOL_NAMES = new Set(DEV_TOOL_DEFINITIONS.map((t) => t.name));
@@ -2909,7 +3528,7 @@ function createDevServer(deps = {}) {
2909
3528
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
2910
3529
  const server = new Server({
2911
3530
  name: "ait-devtools",
2912
- version: "0.1.41"
3531
+ version: "0.1.43"
2913
3532
  }, { capabilities: { tools: {} } });
2914
3533
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
2915
3534
  server.setRequestHandler(CallToolRequestSchema, async (request) => {