@ait-co/devtools 0.1.41 → 0.1.44

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;
@@ -49,6 +53,98 @@ var ChiiAitSource = class {
49
53
  }
50
54
  };
51
55
  //#endregion
56
+ //#region src/mcp/log.ts
57
+ /**
58
+ * Allowed field keys that may pass through to a log line.
59
+ * Unknown keys are dropped. Values are still redact-scanned.
60
+ */
61
+ const ALLOWED_KEYS = new Set([
62
+ "ts",
63
+ "level",
64
+ "event",
65
+ "msg",
66
+ "port",
67
+ "totpEnabled",
68
+ "env",
69
+ "tool",
70
+ "deploymentId",
71
+ "errorKind",
72
+ "reason",
73
+ "prevTargetId",
74
+ "mode"
75
+ ]);
76
+ /**
77
+ * Patterns that match secret values.
78
+ * Match order matters — more-specific patterns first.
79
+ *
80
+ * #268 redact script covers: relay=wss://…, at=<TOTP>, _deploymentId=<uuid>.
81
+ * Here we extend to in-process value-level patterns used in server logs.
82
+ */
83
+ const SECRET_PATTERNS = [
84
+ /^\d{6}$/,
85
+ /^(aitcc_|AITCC_)/i,
86
+ /^[A-Za-z0-9_-]+=.{4,}/,
87
+ /^wss:\/\//,
88
+ /(?:^|[?&])at=[A-Z0-9]{6}/i
89
+ ];
90
+ /**
91
+ * Returns `true` when the string value matches any known-secret pattern.
92
+ * Only string values are tested — numbers/booleans are always safe.
93
+ */
94
+ function isSecretValue(value) {
95
+ return SECRET_PATTERNS.some((re) => re.test(value));
96
+ }
97
+ /**
98
+ * Redacts a single scalar value.
99
+ * - strings: return "***" if the value matches a secret pattern.
100
+ * - other: return as-is.
101
+ */
102
+ function redactValue(value) {
103
+ if (typeof value === "string" && isSecretValue(value)) return "***";
104
+ return value;
105
+ }
106
+ /**
107
+ * Builds a safe log payload from raw fields.
108
+ *
109
+ * - Only keys in `ALLOWED_KEYS` are included.
110
+ * - String values are scanned for secret patterns and replaced with "***".
111
+ * - `ts` and `level` and `event` are always included (they are injected by the
112
+ * logger functions below, not by callers).
113
+ */
114
+ function buildPayload(level, event, fields) {
115
+ const out = {
116
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
117
+ level,
118
+ event
119
+ };
120
+ for (const [key, value] of Object.entries(fields)) {
121
+ if (!ALLOWED_KEYS.has(key)) continue;
122
+ if (key === "ts" || key === "level" || key === "event") continue;
123
+ out[key] = redactValue(value);
124
+ }
125
+ return out;
126
+ }
127
+ /**
128
+ * Writes a single JSON log line to stderr.
129
+ * MCP stdio transport uses stdout; all diagnostics go to stderr.
130
+ */
131
+ function writeLog(level, event, fields = {}) {
132
+ const payload = buildPayload(level, event, fields);
133
+ process.stderr.write(`${JSON.stringify(payload)}\n`);
134
+ }
135
+ /** Log an informational structured event. */
136
+ function logInfo(event, fields = {}) {
137
+ writeLog("info", event, fields);
138
+ }
139
+ /** Log a warning structured event. */
140
+ function logWarn(event, fields = {}) {
141
+ writeLog("warn", event, fields);
142
+ }
143
+ /** Log an error structured event. */
144
+ function logError(event, fields = {}) {
145
+ writeLog("error", event, fields);
146
+ }
147
+ //#endregion
52
148
  //#region src/mcp/chii-connection.ts
53
149
  /**
54
150
  * Production `CdpConnection` backed by the local Chii relay.
@@ -62,6 +158,12 @@ var ChiiAitSource = class {
62
158
  * events in ring buffers the tool layer reads via `getBufferedEvents`.
63
159
  *
64
160
  * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
161
+ *
162
+ * Attach reliability (#281):
163
+ * `refreshTargets()` emits an internal 'target:attached' event whenever a
164
+ * new target is added to the relay. `waitForFirstTarget()` awaits that event
165
+ * (with a polling-interval fallback) so `build_attach_url wait_for_attach`
166
+ * resolves deterministically rather than racing between polling rounds.
65
167
  */
66
168
  /** Max events retained per domain ring buffer. */
67
169
  const DEFAULT_BUFFER_SIZE$1 = 500;
@@ -166,7 +268,7 @@ var ChiiCdpConnection = class {
166
268
  }
167
269
  if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
168
270
  const prevId = this.activeTargetId;
169
- process.stderr.write(`[ait-debug] 이전 page 세션 종료 새 attach로 교체 (prev=${prevId})\n`);
271
+ logInfo("page.detached", { prevTargetId: prevId });
170
272
  this.evictTarget(prevId);
171
273
  }
172
274
  this.targets.clear();
@@ -181,12 +283,73 @@ var ChiiCdpConnection = class {
181
283
  }
182
284
  if (newestTargetId !== null) this.activeTargetId = newestTargetId;
183
285
  else this.activeTargetId = null;
184
- return [...this.targets.values()];
286
+ const result = [...this.targets.values()];
287
+ if (newestTargetId !== null) this.emitter.emit("target:attached", result);
288
+ return result;
185
289
  }
186
290
  listTargets() {
187
291
  return [...this.targets.values()];
188
292
  }
189
293
  /**
294
+ * Waits until at least one target matching `filterFn` is attached, then
295
+ * resolves with the full target list at that moment.
296
+ *
297
+ * Resolution happens on whichever comes first:
298
+ * (a) a `'target:attached'` event from `refreshTargets()` (triggered by
299
+ * the /targets poll finding a new target), OR
300
+ * (b) a `'target:attached'` event from `handleMessage()` (triggered by
301
+ * the first inbound CDP message from a target — confirms the relay
302
+ * websocket has data from the phone, not just a target entry in the map).
303
+ *
304
+ * This dual-signal approach eliminates the polling race that previously
305
+ * caused `wait_for_attach` to resolve before the first CDP message arrived.
306
+ *
307
+ * Falls back to checking `listTargets()` every `pollIntervalMs` in case the
308
+ * EventEmitter is missed (defensive belt-and-suspenders).
309
+ *
310
+ * @param filterFn - Predicate that the returned targets must satisfy.
311
+ * @param timeoutMs - Reject after this many ms (default 90 000).
312
+ * @param pollIntervalMs - Fallback poll interval (default 500ms).
313
+ */
314
+ waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
315
+ const current = this.listTargets();
316
+ if (filterFn(current)) return Promise.resolve(current);
317
+ return new Promise((resolve, reject) => {
318
+ let settled = false;
319
+ let pollHandle = null;
320
+ const settle = (targets) => {
321
+ if (settled) return;
322
+ settled = true;
323
+ clearTimeout(timeoutHandle);
324
+ if (pollHandle !== null) {
325
+ clearInterval(pollHandle);
326
+ pollHandle = null;
327
+ }
328
+ this.emitter.off("target:attached", onAttach);
329
+ resolve(targets);
330
+ };
331
+ const onAttach = (targets) => {
332
+ if (filterFn(targets)) settle(targets);
333
+ };
334
+ const timeoutHandle = setTimeout(() => {
335
+ if (settled) return;
336
+ settled = true;
337
+ if (pollHandle !== null) {
338
+ clearInterval(pollHandle);
339
+ pollHandle = null;
340
+ }
341
+ this.emitter.off("target:attached", onAttach);
342
+ reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
343
+ }, timeoutMs);
344
+ this.emitter.on("target:attached", onAttach);
345
+ pollHandle = setInterval(() => {
346
+ this.refreshTargets().then((targets) => {
347
+ if (filterFn(targets)) settle(targets);
348
+ }, () => {});
349
+ }, pollIntervalMs);
350
+ });
351
+ }
352
+ /**
190
353
  * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
191
354
  * detected since the last `enableDomains()` call, or `null` if none.
192
355
  */
@@ -423,7 +586,12 @@ var ChiiCdpConnection = class {
423
586
  return;
424
587
  }
425
588
  const now = Date.now();
426
- for (const targetId of this.targets.keys()) this.targetLastSeenAt.set(targetId, now);
589
+ let firstMessageSeen = false;
590
+ for (const targetId of this.targets.keys()) {
591
+ if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
592
+ this.targetLastSeenAt.set(targetId, now);
593
+ }
594
+ if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
427
595
  if (typeof message.method !== "string") return;
428
596
  if (message.method === "Inspector.targetCrashed") {
429
597
  this.handleTargetGone("crashed", null);
@@ -493,9 +661,9 @@ var ChiiCdpConnection = class {
493
661
  * in any log, error message, or process output. `verifyAuth` is a black-box
494
662
  * predicate from the caller's perspective; this module only forwards pass/fail.
495
663
  */
496
- const require = createRequire(import.meta.url);
664
+ const require$1 = createRequire(import.meta.url);
497
665
  function loadChiiServer() {
498
- const mod = require("chii");
666
+ const mod = require$1("chii");
499
667
  if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
500
668
  throw new Error("chii server module did not expose start()");
501
669
  }
@@ -548,6 +716,288 @@ async function startChiiRelay(options = {}) {
548
716
  };
549
717
  }
550
718
  //#endregion
719
+ //#region src/mcp/devtools-opener.ts
720
+ /**
721
+ * Base URL for the Chrome DevTools inspector hosted on appspot.
722
+ *
723
+ * The `@` path segment is the "latest / bleeding edge" alias which tracks the
724
+ * current Chrome stable CDP protocol version — compatible with the chobitsu-
725
+ * based CDP that Chii injects. A specific commit hash may be pinned here if
726
+ * a regression is observed.
727
+ */
728
+ const DEVTOOLS_FRONTEND_BASE = "https://chrome-devtools-frontend.appspot.com/serve_file/@/inspector.html";
729
+ /**
730
+ * Assembles the Chrome DevTools inspector URL that connects to a Chii relay
731
+ * WebSocket.
732
+ *
733
+ * The `wss=` parameter expects a host-and-path string without the `wss://`
734
+ * scheme prefix — the DevTools frontend prepends it automatically.
735
+ *
736
+ * @param wssRelayUrl - Full `wss://` URL of the Chii relay (public tunnel).
737
+ * Example: `wss://abc.trycloudflare.com`
738
+ * @param panel - Initial panel. Defaults to `"console"`.
739
+ *
740
+ * @example
741
+ * buildChromeDevtoolsUrl('wss://abc.trycloudflare.com')
742
+ * // → 'https://chrome-devtools-frontend.appspot.com/serve_file/@/inspector.html?wss=abc.trycloudflare.com&panel=console'
743
+ */
744
+ function buildChromeDevtoolsUrl(wssRelayUrl, panel = "console") {
745
+ const wssParam = wssRelayUrl.replace(/^wss:\/\//i, "");
746
+ return `${DEVTOOLS_FRONTEND_BASE}?${new URLSearchParams({
747
+ wss: wssParam,
748
+ panel
749
+ }).toString()}`;
750
+ }
751
+ /**
752
+ * Returns `true` when auto-open is **disabled** via the `AIT_AUTO_DEVTOOLS`
753
+ * env var. Only the explicit `"0"` value disables it; anything else (including
754
+ * absent) leaves auto-open enabled.
755
+ */
756
+ function isAutoDevtoolsDisabled() {
757
+ return process.env.AIT_AUTO_DEVTOOLS === "0";
758
+ }
759
+ /**
760
+ * Opens the given URL in the OS default browser using a platform-appropriate
761
+ * command. Returns `true` on success.
762
+ *
763
+ * Failures are silent from the caller's perspective — the caller should log
764
+ * the URL to stderr as a fallback before calling this function.
765
+ */
766
+ function openUrlInBrowser(url) {
767
+ if (process.env.AIT_AUTO_DEVTOOLS_TEST_SKIP_SPAWN === "1") return false;
768
+ const { spawnSync } = __require("node:child_process");
769
+ const platform = process.platform;
770
+ let candidates;
771
+ if (platform === "darwin") candidates = [{
772
+ cmd: "open",
773
+ args: [url]
774
+ }];
775
+ else if (platform === "win32") candidates = [{
776
+ cmd: "cmd",
777
+ args: [
778
+ "/c",
779
+ "start",
780
+ "",
781
+ url
782
+ ]
783
+ }];
784
+ else candidates = [
785
+ {
786
+ cmd: "xdg-open",
787
+ args: [url]
788
+ },
789
+ {
790
+ cmd: "sensible-browser",
791
+ args: [url]
792
+ },
793
+ {
794
+ cmd: "x-www-browser",
795
+ args: [url]
796
+ }
797
+ ];
798
+ for (const { cmd, args } of candidates) try {
799
+ const result = spawnSync(cmd, args, {
800
+ encoding: "utf8",
801
+ timeout: 5e3
802
+ });
803
+ if (!result.error && result.status === 0) return true;
804
+ } catch {}
805
+ return false;
806
+ }
807
+ /**
808
+ * Manages auto-opening Chrome DevTools exactly once per relay attach session.
809
+ *
810
+ * Create one instance per `runDebugServer` call and pass its `open()` method
811
+ * as the `onFirstAttach` callback to `startAttachWatcher`.
812
+ *
813
+ * The open fires at most once. Subsequent `open()` calls are no-ops.
814
+ * Opt-out and mock-environment guard are checked at call time.
815
+ */
816
+ var AutoDevtoolsOpener = class {
817
+ _opened = false;
818
+ /**
819
+ * Attempts to auto-open Chrome DevTools.
820
+ *
821
+ * No-op when any of the following conditions hold:
822
+ * 1. Already opened this session (`_opened` is true).
823
+ * 2. `AIT_AUTO_DEVTOOLS=0` opt-out is set.
824
+ * 3. Environment is `mock` (env 1 — F12 is already available).
825
+ * 4. `wssRelayUrl` is null/undefined/empty (tunnel not yet up).
826
+ *
827
+ * Always writes the DevTools URL to stderr so the developer can copy it
828
+ * if the browser open fails or the popup is blocked.
829
+ *
830
+ * @param wssRelayUrl - The public `wss://` relay URL (from tunnel status).
831
+ * @param env - Current MCP environment (`mock` | `relay`).
832
+ */
833
+ open(wssRelayUrl, env) {
834
+ if (this._opened) return;
835
+ if (isAutoDevtoolsDisabled()) return;
836
+ if (env === "mock") return;
837
+ if (!wssRelayUrl) return;
838
+ this._opened = true;
839
+ const devtoolsUrl = buildChromeDevtoolsUrl(wssRelayUrl);
840
+ process.stderr.write(`[ait-debug] 기기가 연결됐습니다 — Chrome DevTools를 자동으로 엽니다.
841
+ [ait-debug] Chrome DevTools URL: ${devtoolsUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)
842
+ `);
843
+ if (!openUrlInBrowser(devtoolsUrl)) process.stderr.write("[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\n");
844
+ }
845
+ /** Returns `true` if `open()` has passed all guards and fired once. */
846
+ get opened() {
847
+ return this._opened;
848
+ }
849
+ };
850
+ //#endregion
851
+ //#region src/mcp/environment.ts
852
+ /**
853
+ * URL patterns that mark a CDP target as a real-device WebView relay.
854
+ *
855
+ * - `intoss-private://` is the Toss in-app private scheme — only ever observed
856
+ * inside the real Toss app WebView.
857
+ * - `*.trycloudflare.com` (host suffix) is the cloudflared quick tunnel used as
858
+ * the relay transport. A target whose URL is on that host is, by construction,
859
+ * reached over the relay.
860
+ *
861
+ * Pattern-only matches — no specific tunnel host or deploymentId is hard-coded.
862
+ */
863
+ const RELAY_URL_PATTERNS = [/^intoss-private:\/\//i, /:\/\/[a-z0-9-]+\.trycloudflare\.com(\/|$|:|\?)/i];
864
+ /**
865
+ * Returns true when the URL string looks like a real-device WebView attached
866
+ * over the Chii relay. Used for `getEnvironment()` precedence step 2.
867
+ */
868
+ function isRelayUrl(url) {
869
+ if (typeof url !== "string" || url.length === 0) return false;
870
+ return RELAY_URL_PATTERNS.some((p) => p.test(url));
871
+ }
872
+ /**
873
+ * Test/override hook — when non-null, `getEnvironment()` returns this value
874
+ * regardless of env vars or connection state. Cleared with `null`.
875
+ */
876
+ let envOverride = null;
877
+ /** Parses the `MCP_ENV` env var into a `McpEnvironment` if valid. */
878
+ function readEnvVar() {
879
+ const raw = process.env.MCP_ENV;
880
+ if (raw === "mock" || raw === "relay") return raw;
881
+ }
882
+ /**
883
+ * Returns the current MCP environment, applying the precedence rules:
884
+ * 1. test override (if set)
885
+ * 2. `MCP_ENV` env var
886
+ * 3. CDP target URL pattern match
887
+ * 4. default `mock`
888
+ */
889
+ function getEnvironment(input = {}) {
890
+ if (envOverride !== null) return envOverride;
891
+ const fromEnv = readEnvVar();
892
+ if (fromEnv !== void 0) return fromEnv;
893
+ const { connection } = input;
894
+ if (connection !== void 0) {
895
+ const targets = connection.listTargets();
896
+ for (const t of targets) if (isRelayUrl(t.url)) return "relay";
897
+ }
898
+ return "mock";
899
+ }
900
+ /**
901
+ * Returns the `EnvironmentReason` that drove the current `getEnvironment()`
902
+ * result. Used by stderr logs and the rejection-reason payload on Tier A/B
903
+ * mismatch errors. SECRET-HANDLING: only stable enum strings — no URL or
904
+ * secret value is ever returned.
905
+ */
906
+ function getEnvironmentReason(input = {}) {
907
+ if (envOverride !== null) return envOverride === "mock" ? "env-var-mock" : "env-var-relay";
908
+ const fromEnv = readEnvVar();
909
+ if (fromEnv === "mock") return "env-var-mock";
910
+ if (fromEnv === "relay") return "env-var-relay";
911
+ const { connection } = input;
912
+ if (connection !== void 0) {
913
+ const targets = connection.listTargets();
914
+ for (const t of targets) if (isRelayUrl(t.url)) return "cdp-target-url-relay-pattern";
915
+ }
916
+ return "default-mock";
917
+ }
918
+ //#endregion
919
+ //#region src/mcp/errors.ts
920
+ /**
921
+ * 한국어 한 줄 "원인 + 다음 행동" 포맷으로 에러 결과를 빌드한다.
922
+ *
923
+ * @param message - 사용자에게 보여줄 에러 본문 (원인 + 다음 행동 포함).
924
+ */
925
+ function mcpError(message) {
926
+ return {
927
+ content: [{
928
+ type: "text",
929
+ text: message
930
+ }],
931
+ isError: true
932
+ };
933
+ }
934
+ /**
935
+ * Tier A/B 환경 불일치 거부 메시지.
936
+ *
937
+ * @param toolName - 거부된 tool 이름.
938
+ * @param requiredEnv - 해당 tool이 요구하는 환경 ('mock' | 'relay').
939
+ * @param currentEnv - 현재 세션 환경.
940
+ * @param reason - 환경이 결정된 근거 (EnvironmentReason 문자열).
941
+ */
942
+ function tierRejectionError(toolName, requiredEnv, currentEnv, reason) {
943
+ return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "build_attach_url → QR 스캔으로 실기기를 attach하세요." : "MCP_ENV=mock 또는 relay 환경변수를 확인하세요."}`}\n\n${`tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`}`);
944
+ }
945
+ /**
946
+ * 상태 1: tunnel 미가동 — cloudflared 터널이 아직 뜨지 않았다.
947
+ *
948
+ * `build_attach_url` 호출 시 tunnel.up === false 인 경우.
949
+ */
950
+ function tunnelDownError() {
951
+ return mcpError("cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
952
+ }
953
+ /**
954
+ * 상태 2: page 미attach — 터널은 살아 있으나 아직 페이지가 연결되지 않았다.
955
+ *
956
+ * enableDomains()가 "No mini-app page attached" 에러를 던질 때.
957
+ */
958
+ function pageMissingError(toolName) {
959
+ return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. build_attach_url로 deep link를 생성하고 QR을 스캔해 미니앱을 attach하세요.`);
960
+ }
961
+ /**
962
+ * 상태 3: page crash — 연결됐던 페이지가 crash/destroy됐다.
963
+ *
964
+ * chii-connection 이 'replaced-by-new-attach' / 'targetCrashed' / 'targetDestroyed' 를
965
+ * 던질 때 이 메시지를 사용한다.
966
+ */
967
+ function pageCrashError(toolName) {
968
+ return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 build_attach_url → QR 스캔으로 재attach하세요.`);
969
+ }
970
+ /**
971
+ * 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다 (dogfood 빌드가 아님).
972
+ *
973
+ * call_sdk 호출 시 브리지가 없을 때.
974
+ */
975
+ function sdkAbsentError(toolName) {
976
+ return mcpError(`${toolName ? `${toolName}: ` : ""}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 번들을 재배포한 뒤 재시도하세요.`);
977
+ }
978
+ /**
979
+ * relay WebSocket 연결이 끊겼을 때 — 크래시가 아닌 네트워크/프로세스 종료.
980
+ */
981
+ function relayDisconnectError(toolName) {
982
+ return mcpError(`${toolName ? `${toolName}: ` : ""}relay 연결이 끊겼습니다. list_pages로 상태를 확인하고, 필요하면 앱을 재실행 후 재attach하세요.`);
983
+ }
984
+ /**
985
+ * CDP/AIT 명령 중 발생한 예외를 4상태로 분류해 적절한 에러 결과를 반환한다.
986
+ *
987
+ * - SDK 부재 패턴 (`window.__sdkCall is not available`) → sdkAbsentError
988
+ * - crash 패턴 (`replaced-by-new-attach`, `targetCrashed`, `targetDestroyed`) → pageCrashError
989
+ * - 연결 끊김 패턴 (`relay에 연결되어 있지 않습니다`, `relay WebSocket`) → relayDisconnectError
990
+ * - 그 외 (일반 에러) → 원본 메시지를 포함한 mcpError
991
+ */
992
+ function classifyToolError(err, toolName) {
993
+ const message = err instanceof Error ? err.message : String(err);
994
+ if (message.startsWith("tunnel-down:") || message.includes("터널이 안 떠 있습니다")) return tunnelDownError();
995
+ if (message.startsWith("sdk-absent:") || message.includes("__sdkCall이 주입되지 않았습니다") || message.includes("window.__sdkCall is not available") || message.includes("__sdkCall") && message.includes("not available")) return sdkAbsentError(toolName);
996
+ if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
997
+ if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket")) return relayDisconnectError(toolName);
998
+ return mcpError(`${toolName} 실패: ${message}\nlist_pages로 미니앱이 relay에 attach됐는지 확인하세요.`);
999
+ }
1000
+ //#endregion
551
1001
  //#region src/mcp/local-connection.ts
552
1002
  /**
553
1003
  * Local-browser `CdpConnection` — attaches directly to a Chromium instance
@@ -1049,6 +1499,154 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl) {
1049
1499
  </html>`;
1050
1500
  }
1051
1501
  //#endregion
1502
+ //#region src/mcp/server-lock.ts
1503
+ /**
1504
+ * Single debug session lock for the `devtools-mcp` debug server.
1505
+ *
1506
+ * At most one debug server process should run on a given machine at a time —
1507
+ * multiple concurrent instances create duplicate cloudflared tunnels, waste
1508
+ * resources, and confuse the user about which wssUrl to use.
1509
+ *
1510
+ * ## Lock file
1511
+ *
1512
+ * Location: `~/.ait-devtools/server.lock`
1513
+ *
1514
+ * Schema (JSON):
1515
+ * ```json
1516
+ * { "pid": 12345, "wssUrl": "wss://xxx.trycloudflare.com", "startedAt": "2026-01-01T00:00:00.000Z" }
1517
+ * ```
1518
+ *
1519
+ * ## Behaviour
1520
+ *
1521
+ * - **Acquire**: write PID + wssUrl + startedAt. Returns a `release()` handle.
1522
+ * - **Stale lock recovery**: if the stored PID is no longer alive
1523
+ * (`process.kill(pid, 0)` throws ESRCH), the lock is silently replaced.
1524
+ * - **Live conflict (option B)**: if the stored PID is alive, `acquireLock`
1525
+ * throws `ServerLockConflictError` with the existing PID and wssUrl so the
1526
+ * caller can surface a clear message to the agent.
1527
+ * - **Release**: remove the lock file. Called on graceful shutdown (SIGINT /
1528
+ * SIGTERM / SIGHUP). SIGKILL survivors leave a stale file — the next startup
1529
+ * recovers it automatically via the alive check.
1530
+ *
1531
+ * ## wssUrl update
1532
+ *
1533
+ * The lock is written before cloudflared starts, so `wssUrl` begins as `null`
1534
+ * and is updated in place once the tunnel URL is known via `updateWssUrl`.
1535
+ *
1536
+ * Node-only.
1537
+ */
1538
+ /** Thrown when a live server process already holds the lock. */
1539
+ var ServerLockConflictError = class extends Error {
1540
+ /** PID of the existing server process. */
1541
+ existingPid;
1542
+ /** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */
1543
+ existingWssUrl;
1544
+ constructor(existingPid, existingWssUrl) {
1545
+ const urlNote = existingWssUrl != null ? ` relay URL: ${existingWssUrl}\n` : " relay URL: (tunnel still starting — retry in a moment)\n";
1546
+ super(`A debug server is already running (PID ${existingPid}).\n` + urlNote + `Stop the existing session before starting a new one.
1547
+ If it is already stopped but this error persists, remove the lock file:
1548
+ rm "${lockFilePath()}"`);
1549
+ this.name = "ServerLockConflictError";
1550
+ this.existingPid = existingPid;
1551
+ this.existingWssUrl = existingWssUrl;
1552
+ }
1553
+ };
1554
+ /** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */
1555
+ function lockFilePath() {
1556
+ return join(process.env.AIT_DEVTOOLS_LOCK_DIR ?? join(homedir(), ".ait-devtools"), "server.lock");
1557
+ }
1558
+ function ensureLockDir(lockPath) {
1559
+ mkdirSync(join(lockPath, ".."), { recursive: true });
1560
+ }
1561
+ /**
1562
+ * Returns `true` when the given PID refers to a running process.
1563
+ *
1564
+ * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process
1565
+ * exists and we have permission to signal it; throws ESRCH when it doesn't exist.
1566
+ */
1567
+ function isPidAlive(pid) {
1568
+ try {
1569
+ process.kill(pid, 0);
1570
+ return true;
1571
+ } catch (err) {
1572
+ if (err.code === "EPERM") return true;
1573
+ return false;
1574
+ }
1575
+ }
1576
+ function readLock(lockPath) {
1577
+ if (!existsSync(lockPath)) return null;
1578
+ try {
1579
+ const raw = readFileSync(lockPath, "utf8");
1580
+ const parsed = JSON.parse(raw);
1581
+ if (typeof parsed === "object" && parsed !== null && "pid" in parsed && typeof parsed.pid === "number" && "startedAt" in parsed && typeof parsed.startedAt === "string") {
1582
+ const p = parsed;
1583
+ return {
1584
+ pid: p.pid,
1585
+ wssUrl: typeof p.wssUrl === "string" ? p.wssUrl : null,
1586
+ startedAt: p.startedAt
1587
+ };
1588
+ }
1589
+ return null;
1590
+ } catch {
1591
+ return null;
1592
+ }
1593
+ }
1594
+ function writeLock(lockPath, data) {
1595
+ ensureLockDir(lockPath);
1596
+ writeFileSync(lockPath, JSON.stringify(data, null, 2), { encoding: "utf8" });
1597
+ }
1598
+ function removeLock(lockPath) {
1599
+ try {
1600
+ rmSync(lockPath);
1601
+ } catch {}
1602
+ }
1603
+ /**
1604
+ * Reads the current lock file without acquiring it. Returns the parsed
1605
+ * `LockData` when the file exists and is valid, otherwise `null`. Used by
1606
+ * `get_diagnostics` to surface the `serverLockHolder` field without
1607
+ * interfering with the running lock owner.
1608
+ */
1609
+ function readServerLock() {
1610
+ return readLock(lockFilePath());
1611
+ }
1612
+ /**
1613
+ * Attempts to acquire the server lock.
1614
+ *
1615
+ * - If no lock exists (or the lock is stale): writes a new lock and returns a
1616
+ * `LockHandle` with `updateWssUrl` + `release`.
1617
+ * - If a live process holds the lock: throws `ServerLockConflictError`.
1618
+ *
1619
+ * The initial `wssUrl` in the lock file is `null` — call
1620
+ * `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.
1621
+ */
1622
+ function acquireLock() {
1623
+ const lockPath = lockFilePath();
1624
+ const existing = readLock(lockPath);
1625
+ if (existing !== null) {
1626
+ if (isPidAlive(existing.pid)) throw new ServerLockConflictError(existing.pid, existing.wssUrl);
1627
+ process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
1628
+ }
1629
+ const data = {
1630
+ pid: process.pid,
1631
+ wssUrl: null,
1632
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1633
+ };
1634
+ writeLock(lockPath, data);
1635
+ let released = false;
1636
+ return {
1637
+ updateWssUrl(wssUrl) {
1638
+ if (released) return;
1639
+ data.wssUrl = wssUrl;
1640
+ writeLock(lockPath, data);
1641
+ },
1642
+ release() {
1643
+ if (released) return;
1644
+ released = true;
1645
+ removeLock(lockPath);
1646
+ }
1647
+ };
1648
+ }
1649
+ //#endregion
1052
1650
  //#region src/mcp/deeplink.ts
1053
1651
  /**
1054
1652
  * Build a self-attaching dogfood deep link.
@@ -1327,7 +1925,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1327
1925
  type: "object",
1328
1926
  properties: {},
1329
1927
  required: []
1330
- }
1928
+ },
1929
+ availableIn: "both"
1331
1930
  },
1332
1931
  {
1333
1932
  name: "list_network_requests",
@@ -1336,16 +1935,18 @@ const DEBUG_TOOL_DEFINITIONS = [
1336
1935
  type: "object",
1337
1936
  properties: {},
1338
1937
  required: []
1339
- }
1938
+ },
1939
+ availableIn: "both"
1340
1940
  },
1341
1941
  {
1342
1942
  name: "list_pages",
1343
- description: "Returns the single active page (at most one) the relay sees attached. When a second page attaches, the previous one is evicted (last-attach wins — single-attach model). The result includes `singleAttachModel: true` so the agent knows the array is always 0 or 1 entries. Also returns whether the cloudflared tunnel is up and the public wss relay URL. Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from that target — useful to detect stale entries when the phone app backgrounded). The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint to re-attach. Call this first to confirm a page is attached before reading console/network.",
1943
+ description: "Returns the single active page (at most one) the relay sees attached. When a second page attaches, the previous one is evicted (last-attach wins — single-attach model). The result includes `singleAttachModel: true` so the agent knows the array is always 0 or 1 entries. Also returns whether the cloudflared tunnel is up and the public wss relay URL. The `tunnel` field includes `droppedAt` (ISO timestamp or null/undefined): when non-null the tunnel has permanently dropped after 3 failed reissue attempts — restart the debug server with `npx @ait-co/devtools devtools-mcp`. Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from that target — useful to detect stale entries when the phone app backgrounded). The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint to re-attach. Call this first to confirm a page is attached before reading console/network.",
1344
1944
  inputSchema: {
1345
1945
  type: "object",
1346
1946
  properties: {},
1347
1947
  required: []
1348
- }
1948
+ },
1949
+ availableIn: "both"
1349
1950
  },
1350
1951
  {
1351
1952
  name: "build_attach_url",
@@ -1367,7 +1968,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1367
1968
  }
1368
1969
  },
1369
1970
  required: ["scheme_url"]
1370
- }
1971
+ },
1972
+ availableIn: "relay"
1371
1973
  },
1372
1974
  {
1373
1975
  name: "get_dom_document",
@@ -1376,7 +1978,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1376
1978
  type: "object",
1377
1979
  properties: {},
1378
1980
  required: []
1379
- }
1981
+ },
1982
+ availableIn: "both"
1380
1983
  },
1381
1984
  {
1382
1985
  name: "take_snapshot",
@@ -1385,7 +1988,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1385
1988
  type: "object",
1386
1989
  properties: {},
1387
1990
  required: []
1388
- }
1991
+ },
1992
+ availableIn: "both"
1389
1993
  },
1390
1994
  {
1391
1995
  name: "take_screenshot",
@@ -1394,16 +1998,18 @@ const DEBUG_TOOL_DEFINITIONS = [
1394
1998
  type: "object",
1395
1999
  properties: {},
1396
2000
  required: []
1397
- }
2001
+ },
2002
+ availableIn: "both"
1398
2003
  },
1399
2004
  {
1400
2005
  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.",
2006
+ 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
2007
  inputSchema: {
1403
2008
  type: "object",
1404
2009
  properties: {},
1405
2010
  required: []
1406
- }
2011
+ },
2012
+ availableIn: "both"
1407
2013
  },
1408
2014
  {
1409
2015
  name: "evaluate",
@@ -1415,7 +2021,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1415
2021
  description: "JavaScript expression to evaluate in the page context."
1416
2022
  } },
1417
2023
  required: ["expression"]
1418
- }
2024
+ },
2025
+ availableIn: "both"
1419
2026
  },
1420
2027
  {
1421
2028
  name: "list_exceptions",
@@ -1427,7 +2034,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1427
2034
  description: "Maximum number of exceptions to return (default 50, max 50)."
1428
2035
  } },
1429
2036
  required: []
1430
- }
2037
+ },
2038
+ availableIn: "both"
1431
2039
  },
1432
2040
  {
1433
2041
  name: "call_sdk",
@@ -1446,7 +2054,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1446
2054
  }
1447
2055
  },
1448
2056
  required: ["name"]
1449
- }
2057
+ },
2058
+ availableIn: "both"
1450
2059
  },
1451
2060
  {
1452
2061
  name: "AIT.getSdkCallHistory",
@@ -1455,7 +2064,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1455
2064
  type: "object",
1456
2065
  properties: {},
1457
2066
  required: []
1458
- }
2067
+ },
2068
+ availableIn: "both"
1459
2069
  },
1460
2070
  {
1461
2071
  name: "AIT.getMockState",
@@ -1464,7 +2074,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1464
2074
  type: "object",
1465
2075
  properties: {},
1466
2076
  required: []
1467
- }
2077
+ },
2078
+ availableIn: "both"
1468
2079
  },
1469
2080
  {
1470
2081
  name: "AIT.getOperationalEnvironment",
@@ -1473,7 +2084,21 @@ const DEBUG_TOOL_DEFINITIONS = [
1473
2084
  type: "object",
1474
2085
  properties: {},
1475
2086
  required: []
1476
- }
2087
+ },
2088
+ availableIn: "both"
2089
+ },
2090
+ {
2091
+ name: "get_diagnostics",
2092
+ description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (getEnvironment() result + reason), serverLockHolder (pid + startedAt from the lock file, or null). All fields are nullable — missing data is null, not an error. Tier C (both mock and relay). Call this first when debugging session state.",
2093
+ inputSchema: {
2094
+ type: "object",
2095
+ properties: { recent_errors_limit: {
2096
+ type: "number",
2097
+ description: "Maximum number of recent server-side errors to include (default 10, max 50)."
2098
+ } },
2099
+ required: []
2100
+ },
2101
+ availableIn: "both"
1477
2102
  }
1478
2103
  ];
1479
2104
  const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
@@ -1481,6 +2106,34 @@ function isDebugToolName(name) {
1481
2106
  return DEBUG_TOOL_NAMES.has(name);
1482
2107
  }
1483
2108
  /**
2109
+ * Returns the `ToolAvailability` declared on a registered debug tool, or
2110
+ * `undefined` when the name is not a known debug tool. Used by the tool
2111
+ * registry to filter `tools/list` by current env and by the call handler to
2112
+ * reject env-mismatch invocations.
2113
+ */
2114
+ function getToolAvailability(name) {
2115
+ for (const t of DEBUG_TOOL_DEFINITIONS) if (t.name === name) return t.availableIn;
2116
+ }
2117
+ /**
2118
+ * Returns true when the named tool is available in the given environment.
2119
+ * Unknown tools return `false` — callers should reject them as unknown rather
2120
+ * than as env-mismatched.
2121
+ */
2122
+ function isToolAvailableIn(name, env) {
2123
+ const availability = getToolAvailability(name);
2124
+ if (availability === void 0) return false;
2125
+ if (availability === "both") return true;
2126
+ return availability === env;
2127
+ }
2128
+ /**
2129
+ * Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`
2130
+ * matches the given env. Pure — preserves order; both Tier C ("both") and the
2131
+ * matching single-env tier pass through.
2132
+ */
2133
+ function filterToolsByEnvironment(tools, env) {
2134
+ return tools.filter((t) => t.availableIn === "both" || t.availableIn === env);
2135
+ }
2136
+ /**
1484
2137
  * Tool names that are available before any page attaches (bootstrap tier).
1485
2138
  *
1486
2139
  * `build_attach_url` — pure URL synthesis, no attach needed.
@@ -1489,7 +2142,11 @@ function isDebugToolName(name) {
1489
2142
  * All other tools require an attached page (`enableDomains` must succeed) and
1490
2143
  * are only advertised in `tools/list` once a target appears.
1491
2144
  */
1492
- const BOOTSTRAP_TOOL_NAMES = new Set(["build_attach_url", "list_pages"]);
2145
+ const BOOTSTRAP_TOOL_NAMES = new Set([
2146
+ "build_attach_url",
2147
+ "get_diagnostics",
2148
+ "list_pages"
2149
+ ]);
1493
2150
  /** Renders a CDP `RemoteObject` console arg to a stable display string. */
1494
2151
  function renderRemoteObject(arg) {
1495
2152
  if (arg.value !== void 0) {
@@ -1601,7 +2258,7 @@ function listPages(connection, tunnel) {
1601
2258
  * the scheme authority which is in the caller's input, not ours to own).
1602
2259
  */
1603
2260
  function buildAttachUrl(schemeUrl, tunnel) {
1604
- if (!tunnel.up || tunnel.wssUrl === null) throw new Error("No relay URL yet the cloudflared quick tunnel is not up. Call list_pages to check tunnel status.");
2261
+ if (!tunnel.up || tunnel.wssUrl === null) throw new Error("tunnel-down: cloudflared 터널이 있습니다. MCP 서버를 재시작하거나 잠시 list_pages 터널 상태를 다시 확인하세요.");
1605
2262
  const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
1606
2263
  return {
1607
2264
  attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
@@ -1716,8 +2373,9 @@ function isLaunchFailureStderr(stderr) {
1716
2373
  /**
1717
2374
  * 로컬 HTTP 서버 URL(`http://127.0.0.1:<port>/attach?u=...`)을 OS 기본 브라우저로 연다.
1718
2375
  *
1719
- * platform별 fallback chain으로 시도하며, 모두 실패해도 `opened: false` + `httpUrl`을
1720
- * 반환해 사용자가 직접 브라우저에 붙여넣을 있게 한다.
2376
+ * platform별 fallback chain으로 시도하며, 모두 실패하면 1회 retry를 수행한다
2377
+ * (ephemeral process launch 타이밍 문제 대응). retry까지 실패해도 `opened: false` +
2378
+ * `httpUrl`을 반환해 사용자가 직접 브라우저에 붙여넣을 수 있게 한다.
1721
2379
  *
1722
2380
  * SECRET-HANDLING:
1723
2381
  * - tmp 파일을 만들지 않는다 (HTML/PNG는 HTTP 서버가 메모리에서 응답).
@@ -1730,25 +2388,39 @@ function isLaunchFailureStderr(stderr) {
1730
2388
  */
1731
2389
  async function openQrInBrowser(httpUrl, pngUrl) {
1732
2390
  const { spawnSync } = await import("node:child_process");
1733
- const candidates = getBrowserCandidates(httpUrl);
1734
- const stderrLines = [];
1735
- for (const { cmd, args } of candidates) {
1736
- const result = spawnSync(cmd, args, {
1737
- encoding: "utf8",
1738
- timeout: 5e3
1739
- });
1740
- if (result.error) {
1741
- stderrLines.push(`${cmd}: ${result.error.message}`);
1742
- continue;
2391
+ /**
2392
+ * 번의 fallback chain 시도. 성공하면 열린 후보 cmd를 반환, 실패하면 null.
2393
+ * stderrLines에 후보의 stderr를 누적한다.
2394
+ */
2395
+ function tryOnce(stderrLines) {
2396
+ const candidates = getBrowserCandidates(httpUrl);
2397
+ for (const { cmd, args } of candidates) {
2398
+ const result = spawnSync(cmd, args, {
2399
+ encoding: "utf8",
2400
+ timeout: 5e3
2401
+ });
2402
+ if (result.error) {
2403
+ stderrLines.push(`${cmd}: ${result.error.message}`);
2404
+ continue;
2405
+ }
2406
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
2407
+ if (stderr) stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);
2408
+ if (result.status === 0 && !isLaunchFailureStderr(stderr)) return true;
1743
2409
  }
1744
- const stderr = typeof result.stderr === "string" ? result.stderr : "";
1745
- if (stderr) stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);
1746
- if (result.status === 0 && !isLaunchFailureStderr(stderr)) return {
1747
- opened: true,
1748
- httpUrl,
1749
- pngUrl
1750
- };
2410
+ return false;
1751
2411
  }
2412
+ const stderrLines = [];
2413
+ if (tryOnce(stderrLines)) return {
2414
+ opened: true,
2415
+ httpUrl,
2416
+ pngUrl
2417
+ };
2418
+ if (tryOnce(stderrLines)) return {
2419
+ opened: true,
2420
+ httpUrl,
2421
+ pngUrl,
2422
+ retried: true
2423
+ };
1752
2424
  return {
1753
2425
  opened: false,
1754
2426
  httpUrl,
@@ -1781,11 +2453,13 @@ async function takeScreenshot(connection) {
1781
2453
  * The JS probe injected via `Runtime.evaluate`. It reads:
1782
2454
  * 1. `env(safe-area-inset-*)` via a temporary element with padding set to
1783
2455
  * 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.
2456
+ * 2. SDK insets via a priority chain so the SAME probe works on both relay
2457
+ * (real device) and mock (devtools panel page):
2458
+ * a. `window.__sdk.SafeAreaInsets.get()` dogfood bundle on real device.
2459
+ * b. `window.__sdk.getSafeAreaInsets()` — dogfood bundle (deprecated).
2460
+ * c. `window.__ait.state.safeAreaInsets` — devtools mock state (mock env).
2461
+ * The probe records `sdkInsetsSource` = `'window.__sdk'` | `'window.__ait'`
2462
+ * | `null`. If all paths fail the result carries `sdkInsetsError`.
1789
2463
  * 3. nav bar geometry: the SDK does not expose navBar height as a standalone
1790
2464
  * API — `.ait-navbar` DOM height is read as a cross-check, and
1791
2465
  * `navBarHeightSource` records where it came from.
@@ -1793,9 +2467,15 @@ async function takeScreenshot(connection) {
1793
2467
  *
1794
2468
  * Returns a plain JSON-serialisable object so `returnByValue: true` works.
1795
2469
  *
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.
2470
+ * NOTE: This expression is evaluated in the page context on the real device
2471
+ * (relay) or on the mock panel page. It does not mutate any page state — the
2472
+ * temporary element is removed after reading. No secret or auth token is read
2473
+ * or returned.
2474
+ *
2475
+ * RFC #277 Tier C parity: the SAME probe string runs in both envs. Mock fidelity
2476
+ * comes from the panel's `applyViewport` / `computeSafeAreaInsets` correctly
2477
+ * setting `window.__ait.state.safeAreaInsets` (#275). When that is correct,
2478
+ * the cssEnv + sdkInsets pair returned here matches the relay's shape.
1799
2479
  */
1800
2480
  const SAFE_AREA_PROBE_EXPRESSION = `
1801
2481
  (function() {
@@ -1815,17 +2495,28 @@ const SAFE_AREA_PROBE_EXPRESSION = `
1815
2495
  };
1816
2496
  document.documentElement.removeChild(el);
1817
2497
  var sdkInsets = null;
2498
+ var sdkInsetsSource = null;
1818
2499
  var sdkInsetsError = undefined;
1819
2500
  try {
1820
2501
  var sdk = window.__sdk;
2502
+ var ait = window.__ait;
1821
2503
  if (sdk && sdk.SafeAreaInsets && typeof sdk.SafeAreaInsets.get === 'function') {
1822
2504
  sdkInsets = sdk.SafeAreaInsets.get();
2505
+ sdkInsetsSource = 'window.__sdk';
1823
2506
  } else if (sdk && typeof sdk.getSafeAreaInsets === 'function') {
1824
2507
  sdkInsets = sdk.getSafeAreaInsets();
1825
- } else if (!sdk) {
1826
- sdkInsetsError = 'window.__sdk not available (non-dogfood bundle)';
1827
- } else {
2508
+ sdkInsetsSource = 'window.__sdk';
2509
+ } else if (ait && ait.state && ait.state.safeAreaInsets &&
2510
+ typeof ait.state.safeAreaInsets.top === 'number') {
2511
+ var s = ait.state.safeAreaInsets;
2512
+ sdkInsets = { top: s.top, bottom: s.bottom, left: s.left, right: s.right };
2513
+ sdkInsetsSource = 'window.__ait';
2514
+ } else if (!sdk && !ait) {
2515
+ sdkInsetsError = 'neither window.__sdk (relay) nor window.__ait (mock) available';
2516
+ } else if (sdk) {
1828
2517
  sdkInsetsError = 'neither SafeAreaInsets.get nor getSafeAreaInsets found on window.__sdk';
2518
+ } else {
2519
+ sdkInsetsError = 'window.__ait.state.safeAreaInsets is missing or malformed';
1829
2520
  }
1830
2521
  } catch(e) {
1831
2522
  sdkInsetsError = String(e && e.message || e);
@@ -1842,6 +2533,7 @@ const SAFE_AREA_PROBE_EXPRESSION = `
1842
2533
  var result = {
1843
2534
  cssEnv: cssEnv,
1844
2535
  sdkInsets: sdkInsets,
2536
+ sdkInsetsSource: sdkInsetsSource,
1845
2537
  navBarHeight: navBarHeight,
1846
2538
  navBarHeightSource: navBarHeightSource,
1847
2539
  innerWidth: window.innerWidth,
@@ -1858,9 +2550,11 @@ const SAFE_AREA_PROBE_EXPRESSION = `
1858
2550
  * The probe returns a JSON string (because `returnByValue:true` with a plain
1859
2551
  * object works unreliably across Chii relay versions — stringifying is safer).
1860
2552
  *
2553
+ * `source` is supplied by the caller (`measureSafeArea`) from the env SSoT.
2554
+ *
1861
2555
  * Throws if the result is missing, contains an exception, or cannot be parsed.
1862
2556
  */
1863
- function normalizeSafeAreaResult(rawValue) {
2557
+ function normalizeSafeAreaResult(rawValue, source) {
1864
2558
  if (typeof rawValue !== "string") throw new Error(`measure_safe_area: probe returned unexpected type "${typeof rawValue}" — expected JSON string`);
1865
2559
  let parsed;
1866
2560
  try {
@@ -1889,6 +2583,7 @@ function normalizeSafeAreaResult(rawValue) {
1889
2583
  left: 0
1890
2584
  };
1891
2585
  const sdkInsets = requireInsets("sdkInsets");
2586
+ const sdkInsetsSource = obj.sdkInsetsSource === "window.__sdk" || obj.sdkInsetsSource === "window.__ait" ? obj.sdkInsetsSource : null;
1892
2587
  const sdkInsetsError = typeof obj.sdkInsetsError === "string" ? obj.sdkInsetsError : void 0;
1893
2588
  const navBarHeight = typeof obj.navBarHeight === "number" ? obj.navBarHeight : null;
1894
2589
  const navBarHeightSource = typeof obj.navBarHeightSource === "string" ? obj.navBarHeightSource : "not-exposed-by-sdk";
@@ -1897,8 +2592,10 @@ function normalizeSafeAreaResult(rawValue) {
1897
2592
  const devicePixelRatio = typeof obj.devicePixelRatio === "number" ? obj.devicePixelRatio : 1;
1898
2593
  const userAgent = typeof obj.userAgent === "string" ? obj.userAgent : "";
1899
2594
  return {
2595
+ source,
1900
2596
  cssEnv,
1901
2597
  sdkInsets,
2598
+ sdkInsetsSource,
1902
2599
  ...sdkInsetsError !== void 0 ? { sdkInsetsError } : {},
1903
2600
  navBarHeight,
1904
2601
  navBarHeightSource,
@@ -1912,9 +2609,16 @@ function normalizeSafeAreaResult(rawValue) {
1912
2609
  * Runs the safe-area probe on the attached page and returns a normalized
1913
2610
  * `SafeAreaMeasurement`. Read-only — does not mutate page state.
1914
2611
  *
2612
+ * `source` is supplied by the caller from the env detection SSoT (see
2613
+ * `src/mcp/environment.ts`). The same `Runtime.evaluate` call runs in both
2614
+ * envs — the probe expression tries `window.__sdk` first (relay) then
2615
+ * `window.__ait` (mock), so mock fidelity is enforced by the panel's
2616
+ * `applyViewport`/`computeSafeAreaInsets` keeping `__ait.state.safeAreaInsets`
2617
+ * correct (RFC #277 Tier C parity, #275 model).
2618
+ *
1915
2619
  * Throws on CDP error, probe exception, or result parse failure.
1916
2620
  */
1917
- async function measureSafeArea(connection) {
2621
+ async function measureSafeArea(connection, source) {
1918
2622
  const result = await connection.send("Runtime.evaluate", {
1919
2623
  expression: SAFE_AREA_PROBE_EXPRESSION,
1920
2624
  returnByValue: true,
@@ -1924,7 +2628,7 @@ async function measureSafeArea(connection) {
1924
2628
  const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
1925
2629
  throw new Error(`measure_safe_area: probe threw — ${msg}`);
1926
2630
  }
1927
- return normalizeSafeAreaResult(result.result.value);
2631
+ return normalizeSafeAreaResult(result.result.value, source);
1928
2632
  }
1929
2633
  /**
1930
2634
  * Evaluates an arbitrary JS expression on the attached page via
@@ -1962,7 +2666,7 @@ async function evaluate(connection, expression) {
1962
2666
  * any log or stderr by the caller.
1963
2667
  */
1964
2668
  function buildCallSdkExpression(name, args) {
1965
- return `(async () => { if (typeof window.__sdkCall !== 'function') { return JSON.stringify({ok:false,error:'window.__sdkCall is not available is this a dogfood (__DEBUG_BUILD__) bundle?'}); } try { const r = await window.__sdkCall(${JSON.stringify(name)}, ...${JSON.stringify(args)}); return JSON.stringify({ok:true,value:r}); } catch(e) { return JSON.stringify({ok:false,error:String(e && e.message || e)}); }})()`;
2669
+ return `(async () => { if (typeof window.__sdkCall !== 'function') { return JSON.stringify({ok:false,error:'sdk-absent: window.__sdkCall 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널로 재배포하세요.'}); } try { const r = await window.__sdkCall(${JSON.stringify(name)}, ...${JSON.stringify(args)}); return JSON.stringify({ok:true,value:r}); } catch(e) { return JSON.stringify({ok:false,error:String(e && e.message || e)}); }})()`;
1966
2670
  }
1967
2671
  /**
1968
2672
  * Parses the JSON envelope string returned by the `call_sdk` expression.
@@ -2081,6 +2785,145 @@ function getMockState(source) {
2081
2785
  function getOperationalEnvironment(source) {
2082
2786
  return source.get("AIT.getOperationalEnvironment");
2083
2787
  }
2788
+ /** Secret-redaction patterns applied before error messages enter the buffer. */
2789
+ const SECRET_REDACT_PATTERNS = [
2790
+ [/\bat=([^&\s"']+)/g, "at=<redacted>"],
2791
+ [/((?:set-)?cookie)\s*:\s*.+/gi, "$1: <redacted>"],
2792
+ [/AITCC_API_KEY\s*=\s*\S+/gi, "AITCC_API_KEY=<redacted>"],
2793
+ [/Authorization\s*:\s*.+/gi, "Authorization: <redacted>"],
2794
+ [/\bBearer\s+\S+/g, "Bearer <redacted>"]
2795
+ ];
2796
+ /**
2797
+ * Applies all secret-redaction patterns to an error message string.
2798
+ * Used before storing errors in the `DiagnosticsCollector` ring buffer.
2799
+ *
2800
+ * SECRET-HANDLING: this is the single bottleneck for redaction — all error
2801
+ * strings must pass through here before reaching the buffer.
2802
+ */
2803
+ function redactErrorMessage(message) {
2804
+ let result = message;
2805
+ for (const [pattern, replacement] of SECRET_REDACT_PATTERNS) result = result.replace(pattern, replacement);
2806
+ return result;
2807
+ }
2808
+ /** Default max buffer size for the error ring buffer. */
2809
+ const DEFAULT_ERROR_BUFFER_SIZE = 50;
2810
+ /**
2811
+ * In-memory implementation of `DiagnosticsCollector`. Thread-safe in the
2812
+ * single-threaded Node.js sense (synchronous mutations only).
2813
+ */
2814
+ var InMemoryDiagnosticsCollector = class {
2815
+ buffer = [];
2816
+ maxSize;
2817
+ lastAttachAt = null;
2818
+ lastDetachAt = null;
2819
+ constructor(maxSize = DEFAULT_ERROR_BUFFER_SIZE) {
2820
+ this.maxSize = maxSize;
2821
+ }
2822
+ recordError(message, category) {
2823
+ const entry = {
2824
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2825
+ message: redactErrorMessage(message),
2826
+ ...category !== void 0 ? { category } : {}
2827
+ };
2828
+ this.buffer.push(entry);
2829
+ if (this.buffer.length > this.maxSize) this.buffer.shift();
2830
+ }
2831
+ getRecentErrors(limit) {
2832
+ const cap = Math.min(Math.max(1, limit), DEFAULT_ERROR_BUFFER_SIZE);
2833
+ return this.buffer.length > cap ? this.buffer.slice(this.buffer.length - cap) : [...this.buffer];
2834
+ }
2835
+ recordAttach() {
2836
+ this.lastAttachAt = (/* @__PURE__ */ new Date()).toISOString();
2837
+ }
2838
+ recordDetach() {
2839
+ this.lastDetachAt = (/* @__PURE__ */ new Date()).toISOString();
2840
+ }
2841
+ getLastAttachAt() {
2842
+ return this.lastAttachAt;
2843
+ }
2844
+ getLastDetachAt() {
2845
+ return this.lastDetachAt;
2846
+ }
2847
+ };
2848
+ /**
2849
+ * Reads the `@modelcontextprotocol/sdk` package version from the installed
2850
+ * package's `package.json`. Returns `null` on any error (missing file, JSON
2851
+ * parse failure, etc.) — diagnostics must never throw.
2852
+ *
2853
+ * Node-only — uses dynamic `import()` so it does not pollute the browser
2854
+ * module graph.
2855
+ */
2856
+ async function readMcpSdkVersion() {
2857
+ try {
2858
+ const { createRequire } = await import("node:module");
2859
+ const pkgPath = createRequire(import.meta.url).resolve("@modelcontextprotocol/sdk/package.json");
2860
+ const { readFileSync } = await import("node:fs");
2861
+ const raw = readFileSync(pkgPath, "utf8");
2862
+ const parsed = JSON.parse(raw);
2863
+ return typeof parsed.version === "string" ? parsed.version : null;
2864
+ } catch {
2865
+ return null;
2866
+ }
2867
+ }
2868
+ /**
2869
+ * Returns the `@ait-co/devtools` package version injected at build time via
2870
+ * the `__VERSION__` define. Returns `null` when the global is absent (e.g. in
2871
+ * some test environments that skip the build step).
2872
+ */
2873
+ function readDevtoolsVersion() {
2874
+ try {
2875
+ const v = globalThis.__VERSION__;
2876
+ return typeof v === "string" && v.length > 0 ? v : null;
2877
+ } catch {
2878
+ return null;
2879
+ }
2880
+ }
2881
+ /**
2882
+ * Builds the `get_diagnostics` response. Pure — does not throw; missing data
2883
+ * fields are `null`. Async because `readMcpSdkVersion` needs `import()`.
2884
+ *
2885
+ * SECRET-HANDLING:
2886
+ * - `recentErrors` messages are already redacted by `recordError` (via
2887
+ * `redactErrorMessage`). No additional redaction needed here.
2888
+ * - `tunnel.wssUrl` is a public cloudflared hostname — not a secret.
2889
+ * - Lock file data contains only pid + startedAt + wssUrl — no secrets.
2890
+ */
2891
+ async function getDiagnostics(input) {
2892
+ const { tunnel, connection, env, envReason, collector, readLock: readLockFn, recentErrorsLimit = 10, getMcpVersion = readMcpSdkVersion } = input;
2893
+ const [mcpVersion, devtoolsVersion] = await Promise.all([getMcpVersion(), Promise.resolve(readDevtoolsVersion())]);
2894
+ const lockData = readLockFn();
2895
+ const serverLockHolder = lockData ? {
2896
+ pid: lockData.pid,
2897
+ startedAt: lockData.startedAt,
2898
+ wssUrl: lockData.wssUrl
2899
+ } : null;
2900
+ const tunnelInfo = {
2901
+ up: tunnel.up,
2902
+ wssUrl: tunnel.wssUrl,
2903
+ pid: lockData?.pid ?? null,
2904
+ startedAt: lockData?.startedAt ?? null
2905
+ };
2906
+ let pages = null;
2907
+ if (connection !== void 0) try {
2908
+ pages = listPages(connection, tunnel);
2909
+ } catch {}
2910
+ const limit = Math.min(Math.max(1, recentErrorsLimit), 50);
2911
+ const recentErrors = collector.getRecentErrors(limit);
2912
+ return {
2913
+ mcpVersion,
2914
+ devtoolsVersion,
2915
+ tunnel: tunnelInfo,
2916
+ pages,
2917
+ lastAttachAt: collector.getLastAttachAt(),
2918
+ lastDetachAt: collector.getLastDetachAt(),
2919
+ recentErrors,
2920
+ environment: {
2921
+ env,
2922
+ reason: envReason
2923
+ },
2924
+ serverLockHolder
2925
+ };
2926
+ }
2084
2927
  //#endregion
2085
2928
  //#region src/mcp/totp.ts
2086
2929
  /**
@@ -2171,6 +3014,15 @@ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
2171
3014
  * and would be stale by the time a human scans. The in-app deep-link builder
2172
3015
  * splices the live code at attach time.
2173
3016
  *
3017
+ * Tunnel health probe (`TunnelHealthProbe`):
3018
+ * After the tunnel is up, a periodic HTTP HEAD probe hits the tunnel's
3019
+ * `https://` URL every `probeIntervalMs` (default 60 s). Two consecutive
3020
+ * failures trigger a reissue attempt (spawn a new cloudflared quick tunnel
3021
+ * and redirect traffic). After `MAX_REISSUE_ATTEMPTS` (3) consecutive
3022
+ * reissue failures, the probe gives up and marks the tunnel permanently
3023
+ * dropped — `tunnelStatus.up` becomes false with `droppedAt` set. The caller
3024
+ * should surface this to the agent so the user knows to restart the server.
3025
+ *
2174
3026
  * SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear
2175
3027
  * in any output from this module.
2176
3028
  *
@@ -2293,6 +3145,118 @@ async function printAttachBanner(input) {
2293
3145
  const banner = await renderAttachBanner(input);
2294
3146
  process.stderr.write(`${banner}\n`);
2295
3147
  }
3148
+ /**
3149
+ * Probes `https://` URL with an HTTP HEAD request.
3150
+ * Returns `true` when the server responds (any HTTP status), `false` on
3151
+ * network error or timeout.
3152
+ *
3153
+ * We treat any HTTP response (including 4xx/5xx) as "tunnel alive" because
3154
+ * cloudflared itself responds to the HEAD — if the tunnel process died, the
3155
+ * request fails at the network level rather than returning a status code.
3156
+ *
3157
+ * @param httpsUrl - The `https://` tunnel URL to probe.
3158
+ * @param timeoutMs - Abort timeout in ms. Default 10 000.
3159
+ */
3160
+ async function probeTunnel(httpsUrl, timeoutMs = 1e4) {
3161
+ const { default: https } = await import("node:https");
3162
+ return new Promise((resolve) => {
3163
+ const url = new URL(httpsUrl);
3164
+ const timer = setTimeout(() => {
3165
+ req.destroy();
3166
+ resolve(false);
3167
+ }, timeoutMs);
3168
+ const req = https.request({
3169
+ hostname: url.hostname,
3170
+ port: 443,
3171
+ path: url.pathname || "/",
3172
+ method: "HEAD"
3173
+ }, (_res) => {
3174
+ clearTimeout(timer);
3175
+ _res.resume();
3176
+ resolve(true);
3177
+ });
3178
+ req.on("error", () => {
3179
+ clearTimeout(timer);
3180
+ resolve(false);
3181
+ });
3182
+ req.end();
3183
+ });
3184
+ }
3185
+ /**
3186
+ * Starts a periodic health probe for a cloudflared quick tunnel.
3187
+ *
3188
+ * Every `probeIntervalMs` the probe sends an HTTP HEAD request to the tunnel's
3189
+ * `https://` URL. When `failuresBeforeReissue` consecutive failures are
3190
+ * detected, it attempts to spawn a new tunnel (up to `MAX_REISSUE_ATTEMPTS`
3191
+ * times). On success the caller is notified via `onReissue`; on permanent
3192
+ * failure via `onPermanentDrop`.
3193
+ *
3194
+ * @returns `stop` — call during server shutdown to clear the probe interval.
3195
+ */
3196
+ function startTunnelHealthProbe(initialTunnel, localPort, options) {
3197
+ const { probeIntervalMs = 6e4, failuresBeforeReissue = 2, onReissue, onPermanentDrop, log = (msg) => process.stderr.write(msg), probe = probeTunnel, spawnTunnel = startQuickTunnel } = options;
3198
+ let currentTunnel = initialTunnel;
3199
+ let consecutiveFailures = 0;
3200
+ let reissueAttempts = 0;
3201
+ let stopped = false;
3202
+ const handle = setInterval(() => {
3203
+ (async () => {
3204
+ if (stopped) return;
3205
+ const httpsUrl = currentTunnel.url;
3206
+ if (await probe(httpsUrl)) {
3207
+ if (consecutiveFailures > 0) log("[ait-debug] tunnel health probe: tunnel recovered\n");
3208
+ consecutiveFailures = 0;
3209
+ reissueAttempts = 0;
3210
+ return;
3211
+ }
3212
+ consecutiveFailures += 1;
3213
+ log(`[ait-debug] tunnel health probe: failure ${consecutiveFailures}/${failuresBeforeReissue} (url=${httpsUrl})\n`);
3214
+ if (consecutiveFailures < failuresBeforeReissue) return;
3215
+ reissueAttempts += 1;
3216
+ if (reissueAttempts > 3) return;
3217
+ log(`[ait-debug] tunnel drop detected — reissuing (attempt ${reissueAttempts}/3)\n`);
3218
+ try {
3219
+ const newTunnel = await spawnTunnel(localPort);
3220
+ try {
3221
+ currentTunnel.stop();
3222
+ } catch {}
3223
+ currentTunnel = newTunnel;
3224
+ consecutiveFailures = 0;
3225
+ log(`[ait-debug] tunnel reissued — new relay: ${newTunnel.wssUrl}\n`);
3226
+ onReissue(newTunnel);
3227
+ } catch (err) {
3228
+ const message = err instanceof Error ? err.message : String(err);
3229
+ log(`[ait-debug] tunnel reissue attempt ${reissueAttempts} failed: ${message}\n`);
3230
+ if (reissueAttempts >= 3) {
3231
+ clearInterval(handle);
3232
+ stopped = true;
3233
+ const droppedAt = (/* @__PURE__ */ new Date()).toISOString();
3234
+ log(`[ait-debug] tunnel permanently dropped after 3 reissue attempts — restart the debug server to continue (npx @ait-co/devtools devtools-mcp).
3235
+ `);
3236
+ onPermanentDrop(droppedAt);
3237
+ }
3238
+ }
3239
+ })();
3240
+ }, probeIntervalMs);
3241
+ return { stop() {
3242
+ stopped = true;
3243
+ clearInterval(handle);
3244
+ } };
3245
+ }
3246
+ /**
3247
+ * Builds a `TunnelStatus` snapshot that includes drop state.
3248
+ *
3249
+ * Convenience helper for callers (debug-server) that maintain a mutable
3250
+ * `tunnelStatus` object — keeps the shape construction in one place.
3251
+ */
3252
+ function makeTunnelStatus(up, wssUrl, droppedAt = null, reissueAttempts = 0) {
3253
+ return {
3254
+ up,
3255
+ wssUrl,
3256
+ droppedAt,
3257
+ reissueAttempts
3258
+ };
3259
+ }
2296
3260
  //#endregion
2297
3261
  //#region src/mcp/debug-server.ts
2298
3262
  /**
@@ -2339,6 +3303,65 @@ async function printAttachBanner(input) {
2339
3303
  * Node-only.
2340
3304
  */
2341
3305
  /**
3306
+ * Parses `_deploymentId` from the query string of a scheme URL.
3307
+ *
3308
+ * Returns `null` when the param is absent or empty — callers treat that as
3309
+ * "no deploymentId filter; match on presence only" and fall back to the
3310
+ * original `attachedPages.length > 0` condition.
3311
+ *
3312
+ * SECRET-HANDLING: deploymentId is a public identifier and may appear in
3313
+ * debug output. Never confuse it with TOTP secrets or relay tunnel URLs.
3314
+ */
3315
+ function extractDeploymentId(schemeUrl) {
3316
+ try {
3317
+ const qIndex = schemeUrl.indexOf("?");
3318
+ if (qIndex === -1) return null;
3319
+ const id = new URLSearchParams(schemeUrl.slice(qIndex + 1)).get("_deploymentId");
3320
+ return id && id.length > 0 ? id : null;
3321
+ } catch {
3322
+ return null;
3323
+ }
3324
+ }
3325
+ /**
3326
+ * Waits for the first target matching `filterFn` to attach, using the
3327
+ * event-driven `waitForFirstTarget()` on `ChiiCdpConnection` instances, or
3328
+ * falling back to a polling loop for generic `CdpConnection` fakes (tests).
3329
+ *
3330
+ * This eliminates the polling-only race that previously caused `wait_for_attach`
3331
+ * to resolve before the relay had observed the first inbound CDP message from
3332
+ * the phone.
3333
+ *
3334
+ * @param connection - The CDP connection (production or fake).
3335
+ * @param filterFn - Resolves when this predicate is satisfied.
3336
+ * @param timeoutMs - Maximum wait time in ms.
3337
+ * @param pollIntervalMs - Fallback poll interval for non-ChiiCdpConnection.
3338
+ */
3339
+ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs = 1e3) {
3340
+ if (connection instanceof ChiiCdpConnection) return connection.waitForFirstTarget(filterFn, timeoutMs, pollIntervalMs);
3341
+ return new Promise((resolve, reject) => {
3342
+ const deadline = Date.now() + timeoutMs;
3343
+ let settled = false;
3344
+ const poll = setInterval(() => {
3345
+ const targets = connection.listTargets();
3346
+ if (filterFn(targets)) {
3347
+ settled = true;
3348
+ clearInterval(poll);
3349
+ resolve(targets);
3350
+ } else if (Date.now() >= deadline) {
3351
+ settled = true;
3352
+ clearInterval(poll);
3353
+ reject(/* @__PURE__ */ new Error(`waitForAttachWithEvents: 타임아웃 (${timeoutMs}ms)`));
3354
+ }
3355
+ }, pollIntervalMs);
3356
+ const targets = connection.listTargets();
3357
+ if (!settled && filterFn(targets)) {
3358
+ settled = true;
3359
+ clearInterval(poll);
3360
+ resolve(targets);
3361
+ }
3362
+ });
3363
+ }
3364
+ /**
2342
3365
  * Builds the debug-mode MCP server around an injected CDP connection + AIT
2343
3366
  * source + tunnel status getter. Pure wiring — does not start a relay or
2344
3367
  * tunnel, which is what makes the tool surface unit-testable.
@@ -2351,13 +3374,19 @@ async function printAttachBanner(input) {
2351
3374
  * naturally via `enableDomains`). The tier only controls visibility.
2352
3375
  */
2353
3376
  function createDebugServer(deps) {
2354
- const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer } = deps;
3377
+ const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep } = deps;
3378
+ const resolveEnvironment = getEnvDep ?? (() => getEnvironment({ connection }));
3379
+ const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({ connection }));
3380
+ const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
2355
3381
  const server = new Server({
2356
3382
  name: "ait-debug",
2357
- version: "0.1.41"
3383
+ version: "0.1.44"
2358
3384
  }, { capabilities: { tools: { listChanged: true } } });
2359
3385
  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 })) };
3386
+ const env = resolveEnvironment();
3387
+ const attached = connection.listTargets().length > 0;
3388
+ const envFiltered = filterToolsByEnvironment(DEBUG_TOOL_DEFINITIONS, env);
3389
+ return { tools: attached ? envFiltered.map((tool) => ({ ...tool })) : envFiltered.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
2361
3390
  });
2362
3391
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2363
3392
  const name = request.params.name;
@@ -2368,6 +3397,19 @@ function createDebugServer(deps) {
2368
3397
  }],
2369
3398
  isError: true
2370
3399
  };
3400
+ const env = resolveEnvironment();
3401
+ if (!isToolAvailableIn(name, env)) {
3402
+ const requiredEnv = getToolAvailability(name) ?? "unknown";
3403
+ const envReason = resolveEnvironmentReason();
3404
+ logWarn("tool.error", {
3405
+ tool: name,
3406
+ errorKind: "tier-filter",
3407
+ requiredEnv,
3408
+ currentEnv: env,
3409
+ envReason
3410
+ });
3411
+ return tierRejectionError(name, requiredEnv, env, envReason);
3412
+ }
2371
3413
  if (isAitToolName(name)) try {
2372
3414
  await connection.enableDomains();
2373
3415
  switch (name) {
@@ -2379,78 +3421,147 @@ function createDebugServer(deps) {
2379
3421
  } catch (err) {
2380
3422
  return errorResult(err, name);
2381
3423
  }
3424
+ if (name === "get_diagnostics") try {
3425
+ const rawLimit = request.params.arguments?.recent_errors_limit;
3426
+ const recentErrorsLimit = typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 10;
3427
+ return jsonResult$1(await getDiagnostics({
3428
+ tunnel: getTunnelStatus(),
3429
+ connection,
3430
+ env: resolveEnvironment(),
3431
+ envReason: resolveEnvironmentReason(),
3432
+ collector,
3433
+ readLock: readServerLock,
3434
+ recentErrorsLimit
3435
+ }));
3436
+ } catch (err) {
3437
+ return errorResult(err, name);
3438
+ }
2382
3439
  if (name === "build_attach_url") {
2383
3440
  const schemeUrl = request.params.arguments?.scheme_url;
2384
- if (typeof schemeUrl !== "string" || schemeUrl === "") return {
2385
- content: [{
2386
- type: "text",
2387
- text: "build_attach_url requires a non-empty scheme_url."
2388
- }],
2389
- isError: true
2390
- };
3441
+ if (typeof schemeUrl !== "string" || schemeUrl === "") return mcpError("build_attach_url: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요.");
2391
3442
  const waitForAttach = request.params.arguments?.wait_for_attach === true;
2392
3443
  const openInBrowser = request.params.arguments?.open_in_browser !== false;
3444
+ const deploymentId = extractDeploymentId(schemeUrl);
3445
+ if (!deploymentId) logInfo("tool.call", {
3446
+ tool: "build_attach_url",
3447
+ msg: "no _deploymentId in scheme_url; matching on presence only"
3448
+ });
3449
+ /** Returns true when the page list satisfies the attach condition. */
3450
+ const isMatchingPage = (pages) => {
3451
+ if (pages.length === 0) return false;
3452
+ if (deploymentId === null) return true;
3453
+ return pages.some((p) => p.url.includes(deploymentId));
3454
+ };
3455
+ /** Builds a timeout error message with diagnostic context. */
3456
+ const buildTimeoutError = (baseText, timeoutSec, observed) => {
3457
+ const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
3458
+ const observedNote = observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : "";
3459
+ return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
3460
+ };
2393
3461
  try {
2394
3462
  const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
2395
3463
  const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
2396
3464
  const header = "This tool result is shown to the user directly — do NOT re-print the QR below in your reply (it wastes output tokens). Just tell the user to scan the QR in this output (Ctrl+O to expand if collapsed).";
2397
- if (openInBrowser && canOpenBrowser() && qrHttpServer) {
3465
+ const guiAvailable = canOpenBrowser();
3466
+ if (openInBrowser && !guiAvailable) {
3467
+ const headlessNote = "[open_in_browser] GUI 환경이 감지되지 않았습니다 (headless/remote 환경). open_in_browser=false로 자동 폴백합니다. 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
3468
+ const qrHeadless = await renderQr(attachUrl);
3469
+ const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
3470
+ attachUrl,
3471
+ relayUrl
3472
+ }, null, 2)}\n\n${qrHeadless}`;
3473
+ if (!waitForAttach) return { content: [{
3474
+ type: "text",
3475
+ text: headlessText
3476
+ }] };
3477
+ let attachedPagesHl = [];
3478
+ try {
3479
+ attachedPagesHl = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3480
+ } catch {
3481
+ attachedPagesHl = connection.listTargets();
3482
+ return {
3483
+ content: [{
3484
+ type: "text",
3485
+ text: buildTimeoutError(headlessText, waitForAttachTimeoutMs / 1e3, attachedPagesHl)
3486
+ }],
3487
+ isError: true
3488
+ };
3489
+ }
3490
+ const pagesResultHl = listPages(connection, getTunnelStatus());
3491
+ return { content: [{
3492
+ type: "text",
3493
+ text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
3494
+ }] };
3495
+ }
3496
+ if (openInBrowser && guiAvailable && qrHttpServer) {
2398
3497
  const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
2399
3498
  if (browserResult.opened) {
2400
- const shortText = `${warningPrefix}${header}\n${JSON.stringify({ relayUrl }, null, 2)}\n\n브라우저에서 QR을 열었습니다. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
3499
+ const retriedNote = browserResult.retried ? " (1회 retry 성공)" : "";
3500
+ const openResult = {
3501
+ attempted: true,
3502
+ succeeded: true,
3503
+ ...browserResult.retried ? { retried: true } : {}
3504
+ };
3505
+ const shortText = `${warningPrefix}${header}\n${JSON.stringify({
3506
+ relayUrl,
3507
+ openResult
3508
+ }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
2401
3509
  if (!waitForAttach) return { content: [{
2402
3510
  type: "text",
2403
3511
  text: shortText
2404
3512
  }] };
2405
- const POLL_INTERVAL_MS = 1e3;
2406
- const TIMEOUT_MS = waitForAttachTimeoutMs;
2407
- const deadline = Date.now() + TIMEOUT_MS;
2408
3513
  let attachedPages = [];
2409
- while (Date.now() < deadline) {
3514
+ try {
3515
+ attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3516
+ } catch {
2410
3517
  attachedPages = connection.listTargets();
2411
- if (attachedPages.length > 0) break;
2412
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
3518
+ return {
3519
+ content: [{
3520
+ type: "text",
3521
+ text: buildTimeoutError(shortText, waitForAttachTimeoutMs / 1e3, attachedPages)
3522
+ }],
3523
+ isError: true
3524
+ };
2413
3525
  }
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
3526
  const pagesResult = listPages(connection, getTunnelStatus());
2422
3527
  return { content: [{
2423
3528
  type: "text",
2424
3529
  text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
2425
3530
  }] };
2426
3531
  }
3532
+ const openResult = {
3533
+ attempted: true,
3534
+ succeeded: false,
3535
+ failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
3536
+ pngUrl: browserResult.pngUrl,
3537
+ ...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
3538
+ };
2427
3539
  const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
2428
- const fallbackNote = `브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:\n${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
3540
+ const fallbackNote = `[open_in_browser] 브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:
3541
+ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
2429
3542
  const qr = await renderQr(attachUrl);
2430
3543
  const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
2431
3544
  attachUrl,
2432
- relayUrl
3545
+ relayUrl,
3546
+ openResult
2433
3547
  }, null, 2)}\n\n${qr}`;
2434
3548
  if (!waitForAttach) return { content: [{
2435
3549
  type: "text",
2436
3550
  text: baseText
2437
3551
  }] };
2438
- const POLL_INTERVAL_MS_FB = 1e3;
2439
- const TIMEOUT_MS_FB = waitForAttachTimeoutMs;
2440
- const deadline2 = Date.now() + TIMEOUT_MS_FB;
2441
3552
  let attachedPagesFb = [];
2442
- while (Date.now() < deadline2) {
3553
+ try {
3554
+ attachedPagesFb = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3555
+ } catch {
2443
3556
  attachedPagesFb = connection.listTargets();
2444
- if (attachedPagesFb.length > 0) break;
2445
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS_FB));
3557
+ return {
3558
+ content: [{
3559
+ type: "text",
3560
+ text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPagesFb)
3561
+ }],
3562
+ isError: true
3563
+ };
2446
3564
  }
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
3565
  const pagesResultFb = listPages(connection, getTunnelStatus());
2455
3566
  return { content: [{
2456
3567
  type: "text",
@@ -2466,22 +3577,19 @@ function createDebugServer(deps) {
2466
3577
  type: "text",
2467
3578
  text: baseText
2468
3579
  }] };
2469
- const POLL_INTERVAL_MS = 1e3;
2470
- const TIMEOUT_MS = waitForAttachTimeoutMs;
2471
- const deadline = Date.now() + TIMEOUT_MS;
2472
3580
  let attachedPages = [];
2473
- while (Date.now() < deadline) {
3581
+ try {
3582
+ attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3583
+ } catch {
2474
3584
  attachedPages = connection.listTargets();
2475
- if (attachedPages.length > 0) break;
2476
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
3585
+ return {
3586
+ content: [{
3587
+ type: "text",
3588
+ text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPages)
3589
+ }],
3590
+ isError: true
3591
+ };
2477
3592
  }
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
3593
  const pagesResult = listPages(connection, getTunnelStatus());
2486
3594
  return { content: [{
2487
3595
  type: "text",
@@ -2494,15 +3602,13 @@ function createDebugServer(deps) {
2494
3602
  try {
2495
3603
  await connection.enableDomains();
2496
3604
  } catch (err) {
2497
- const message = err instanceof Error ? err.message : String(err);
2498
- if (name === "list_pages") return jsonResult$1(listPages(connection, getTunnelStatus()));
2499
- return {
2500
- content: [{
2501
- type: "text",
2502
- text: `${message}\nCall list_pages to confirm a mini-app has attached over the relay.`
2503
- }],
2504
- isError: true
2505
- };
3605
+ if (name === "list_pages") {
3606
+ if (connection instanceof ChiiCdpConnection) try {
3607
+ await connection.refreshTargets();
3608
+ } catch {}
3609
+ return jsonResult$1(listPages(connection, getTunnelStatus()));
3610
+ }
3611
+ return classifyEnableDomainError(err, name);
2506
3612
  }
2507
3613
  try {
2508
3614
  switch (name) {
@@ -2512,7 +3618,11 @@ function createDebugServer(deps) {
2512
3618
  return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
2513
3619
  }
2514
3620
  case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
2515
- case "list_pages": return jsonResult$1(listPages(connection, getTunnelStatus()));
3621
+ case "list_pages":
3622
+ if (connection instanceof ChiiCdpConnection) try {
3623
+ await connection.refreshTargets();
3624
+ } catch {}
3625
+ return jsonResult$1(listPages(connection, getTunnelStatus()));
2516
3626
  case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
2517
3627
  case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
2518
3628
  case "take_screenshot": {
@@ -2523,29 +3633,19 @@ function createDebugServer(deps) {
2523
3633
  mimeType: shot.mimeType
2524
3634
  }] };
2525
3635
  }
2526
- case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection));
3636
+ case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection, resolveEnvironment()));
2527
3637
  case "evaluate": {
2528
3638
  const expression = request.params.arguments?.expression;
2529
- if (typeof expression !== "string" || expression === "") return {
2530
- content: [{
2531
- type: "text",
2532
- text: "evaluate requires a non-empty expression."
2533
- }],
2534
- isError: true
2535
- };
3639
+ if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
2536
3640
  return jsonResult$1(await evaluate(connection, expression));
2537
3641
  }
2538
3642
  case "call_sdk": {
2539
3643
  const sdkName = request.params.arguments?.name;
2540
- if (typeof sdkName !== "string" || sdkName === "") return {
2541
- content: [{
2542
- type: "text",
2543
- text: "call_sdk requires a non-empty name."
2544
- }],
2545
- isError: true
2546
- };
3644
+ if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
2547
3645
  const rawArgs = request.params.arguments?.args;
2548
- return jsonResult$1(await callSdk(connection, sdkName, Array.isArray(rawArgs) ? rawArgs : []));
3646
+ const sdkResult = await callSdk(connection, sdkName, Array.isArray(rawArgs) ? rawArgs : []);
3647
+ if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
3648
+ return jsonResult$1(sdkResult);
2549
3649
  }
2550
3650
  default: return unknownTool(name);
2551
3651
  }
@@ -2562,22 +3662,29 @@ function jsonResult$1(value) {
2562
3662
  }] };
2563
3663
  }
2564
3664
  function unknownTool(name) {
2565
- return {
2566
- content: [{
2567
- type: "text",
2568
- text: `Unknown tool: ${name}`
2569
- }],
2570
- isError: true
2571
- };
3665
+ return mcpError(`알 수 없는 tool: ${name}`);
3666
+ }
3667
+ /**
3668
+ * enableDomains()가 던진 에러를 4상태로 분류해 적절한 메시지를 반환한다.
3669
+ *
3670
+ * - "No mini-app page attached" → page 미attach (상태 2)
3671
+ * - crash/destroy/replaced 패턴 → page crash (상태 3)
3672
+ * - relay disconnect 패턴 → relay 연결 끊김
3673
+ * - 그 외 → 원본 메시지 + list_pages 안내
3674
+ */
3675
+ function classifyEnableDomainError(err, toolName) {
3676
+ const message = err instanceof Error ? err.message : String(err);
3677
+ if (message.includes("No mini-app page attached") || message.includes("페이지가 attach 안")) return pageMissingError(toolName);
3678
+ if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
3679
+ if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket") || message.includes("Chii relay connection closed")) return relayDisconnectError(toolName);
3680
+ return classifyToolError(err, toolName);
2572
3681
  }
3682
+ /**
3683
+ * CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
3684
+ * debug-server 내부 try/catch 블록에서 공통으로 사용한다.
3685
+ */
2573
3686
  function errorResult(err, name) {
2574
- return {
2575
- content: [{
2576
- 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.`
2578
- }],
2579
- isError: true
2580
- };
3687
+ return classifyToolError(err, name);
2581
3688
  }
2582
3689
  /**
2583
3690
  * Starts a polling watcher that detects the first 0→N target transition on
@@ -2588,19 +3695,28 @@ function errorResult(err, name) {
2588
3695
  * `server.sendToolListChanged()` exactly once — on the first transition — then
2589
3696
  * clears itself. Shutdown calls `stop()` to clear the interval.
2590
3697
  *
3698
+ * `onFirstAttach` is called once on the 0→N transition (or immediately when
3699
+ * already attached). Use this to trigger side-effects such as auto-opening
3700
+ * Chrome DevTools (issue #282). The callback is optional; omitting it preserves
3701
+ * the previous behaviour exactly.
3702
+ *
2591
3703
  * SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
2592
3704
  * Only an attach-detected stderr line is emitted (no target details).
2593
3705
  *
2594
3706
  * @returns `stop` — call this during shutdown to clear the interval.
2595
3707
  */
2596
- function startAttachWatcher(connection, server, intervalMs = 1e3) {
3708
+ function startAttachWatcher(connection, server, intervalMs = 1e3, onFirstAttach) {
2597
3709
  let wasAttached = connection.listTargets().length > 0;
2598
- if (wasAttached) server.sendToolListChanged();
3710
+ if (wasAttached) {
3711
+ server.sendToolListChanged();
3712
+ onFirstAttach?.();
3713
+ }
2599
3714
  const handle = setInterval(() => {
2600
3715
  const isAttached = connection.listTargets().length > 0;
2601
3716
  if (!wasAttached && isAttached) {
2602
3717
  wasAttached = true;
2603
3718
  server.sendToolListChanged();
3719
+ onFirstAttach?.();
2604
3720
  clearInterval(handle);
2605
3721
  }
2606
3722
  }, intervalMs);
@@ -2640,6 +3756,7 @@ function buildRelayVerifyAuth() {
2640
3756
  * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
2641
3757
  */
2642
3758
  async function runDebugServer(options = {}) {
3759
+ const lockHandle = acquireLock();
2643
3760
  const relayPort = options.relayPort ?? 0;
2644
3761
  const verifyAuth = buildRelayVerifyAuth();
2645
3762
  const totpEnabled = verifyAuth !== void 0;
@@ -2647,26 +3764,45 @@ async function runDebugServer(options = {}) {
2647
3764
  port: relayPort,
2648
3765
  verifyAuth
2649
3766
  });
3767
+ logInfo("server.start", {
3768
+ port: relay.port,
3769
+ totpEnabled
3770
+ });
2650
3771
  let tunnel = null;
2651
- let tunnelStatus = {
2652
- up: false,
2653
- wssUrl: null
2654
- };
3772
+ let tunnelStatus = makeTunnelStatus(false, null);
2655
3773
  generateAttachToken();
3774
+ let tunnelProbe = null;
2656
3775
  startQuickTunnel(relay.port).then((t) => {
2657
3776
  tunnel = t;
2658
- tunnelStatus = {
2659
- up: true,
2660
- wssUrl: t.wssUrl
2661
- };
3777
+ tunnelStatus = makeTunnelStatus(true, t.wssUrl);
3778
+ lockHandle.updateWssUrl(t.wssUrl);
3779
+ logInfo("tunnel.up", { totpEnabled });
3780
+ tunnelProbe = startTunnelHealthProbe(t, relay.port, {
3781
+ onReissue: (newTunnel) => {
3782
+ tunnel = newTunnel;
3783
+ tunnelStatus = makeTunnelStatus(true, newTunnel.wssUrl, null, 0);
3784
+ lockHandle.updateWssUrl(newTunnel.wssUrl);
3785
+ printAttachBanner({
3786
+ wssUrl: newTunnel.wssUrl,
3787
+ totpEnabled
3788
+ }).then(() => {
3789
+ logInfo("tunnel.up", {
3790
+ totpEnabled,
3791
+ reissued: true
3792
+ });
3793
+ });
3794
+ },
3795
+ onPermanentDrop: (droppedAt) => {
3796
+ tunnelStatus = makeTunnelStatus(false, null, droppedAt, 3);
3797
+ logError("tunnel.down", { msg: `tunnel permanently dropped (${droppedAt}). Restart: npx @ait-co/devtools devtools-mcp` });
3798
+ }
3799
+ });
2662
3800
  return printAttachBanner({
2663
3801
  wssUrl: t.wssUrl,
2664
3802
  totpEnabled
2665
3803
  });
2666
3804
  }, (err) => {
2667
- const message = err instanceof Error ? err.message : String(err);
2668
- process.stderr.write(`[ait-debug] Failed to open cloudflared quick tunnel: ${message}\n[ait-debug] The relay is up locally; attach over the public URL is unavailable until the tunnel starts.
2669
- `);
3805
+ logError("tunnel.down", { msg: `Failed to open cloudflared quick tunnel: ${err instanceof Error ? err.message : String(err)}. The relay is up locally; attach over the public URL is unavailable until the tunnel starts.` });
2670
3806
  });
2671
3807
  const connection = new ChiiCdpConnection({ relayBaseUrl: relay.baseUrl });
2672
3808
  const aitSource = new ChiiAitSource(connection);
@@ -2674,16 +3810,18 @@ async function runDebugServer(options = {}) {
2674
3810
  try {
2675
3811
  qrServer = await startQrHttpServer();
2676
3812
  } catch (err) {
2677
- const message = err instanceof Error ? err.message : String(err);
2678
- process.stderr.write(`[ait-debug] QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}\n`);
3813
+ logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
2679
3814
  }
3815
+ const devtoolsOpener = new AutoDevtoolsOpener();
3816
+ const diagnosticsCollector = new InMemoryDiagnosticsCollector();
2680
3817
  const server = createDebugServer({
2681
3818
  connection,
2682
3819
  aitSource,
2683
3820
  getTunnelStatus: () => tunnelStatus,
2684
3821
  get qrHttpServer() {
2685
3822
  return qrServer;
2686
- }
3823
+ },
3824
+ diagnosticsCollector
2687
3825
  });
2688
3826
  const transport = new StdioServerTransport();
2689
3827
  let closed = false;
@@ -2692,11 +3830,13 @@ async function runDebugServer(options = {}) {
2692
3830
  if (closed) return;
2693
3831
  closed = true;
2694
3832
  attachWatcher?.stop();
3833
+ tunnelProbe?.stop();
2695
3834
  connection.close();
2696
3835
  tunnel?.stop();
2697
3836
  relay.close();
2698
3837
  server.close();
2699
3838
  qrServer?.close();
3839
+ lockHandle.release();
2700
3840
  };
2701
3841
  process.once("SIGINT", shutdown);
2702
3842
  process.once("SIGTERM", shutdown);
@@ -2705,21 +3845,32 @@ async function runDebugServer(options = {}) {
2705
3845
  if (!closed) {
2706
3846
  closed = true;
2707
3847
  attachWatcher?.stop();
3848
+ tunnelProbe?.stop();
2708
3849
  tunnel?.stop();
3850
+ lockHandle.release();
2709
3851
  }
2710
3852
  });
2711
3853
  process.on("uncaughtException", (err) => {
2712
- process.stderr.write(`[ait-debug] uncaughtException: ${String(err)}\n`);
3854
+ logError("tool.error", {
3855
+ msg: `uncaughtException: ${String(err)}`,
3856
+ errorKind: "uncaught"
3857
+ });
2713
3858
  shutdown();
2714
3859
  process.exit(1);
2715
3860
  });
2716
3861
  process.on("unhandledRejection", (reason) => {
2717
- process.stderr.write(`[ait-debug] unhandledRejection: ${String(reason)}\n`);
3862
+ logError("tool.error", {
3863
+ msg: `unhandledRejection: ${String(reason)}`,
3864
+ errorKind: "unhandled-rejection"
3865
+ });
2718
3866
  shutdown();
2719
3867
  process.exit(1);
2720
3868
  });
2721
3869
  await server.connect(transport);
2722
- attachWatcher = startAttachWatcher(connection, server);
3870
+ attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
3871
+ diagnosticsCollector.recordAttach();
3872
+ devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({ connection }));
3873
+ });
2723
3874
  }
2724
3875
  /**
2725
3876
  * Boots the local-browser debug stack and serves it over stdio:
@@ -2740,6 +3891,7 @@ async function runDebugServer(options = {}) {
2740
3891
  * expected and noted in the PR as an explicit out-of-scope follow-up.
2741
3892
  */
2742
3893
  async function runLocalDebugServer(options = {}) {
3894
+ const lockHandle = acquireLock();
2743
3895
  const chromium = await launchChromium({
2744
3896
  port: options.cdpPort ?? 0,
2745
3897
  devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
@@ -2766,6 +3918,7 @@ async function runLocalDebugServer(options = {}) {
2766
3918
  connection.close();
2767
3919
  chromium.stop();
2768
3920
  server.close();
3921
+ lockHandle.release();
2769
3922
  };
2770
3923
  process.once("SIGINT", shutdown);
2771
3924
  process.once("SIGTERM", shutdown);
@@ -2775,15 +3928,24 @@ async function runLocalDebugServer(options = {}) {
2775
3928
  closed = true;
2776
3929
  attachWatcher?.stop();
2777
3930
  chromium.stop();
3931
+ lockHandle.release();
2778
3932
  }
2779
3933
  });
2780
3934
  process.on("uncaughtException", (err) => {
2781
- process.stderr.write(`[ait-local-debug] uncaughtException: ${String(err)}\n`);
3935
+ logError("tool.error", {
3936
+ msg: `uncaughtException: ${String(err)}`,
3937
+ errorKind: "uncaught",
3938
+ mode: "local"
3939
+ });
2782
3940
  shutdown();
2783
3941
  process.exit(1);
2784
3942
  });
2785
3943
  process.on("unhandledRejection", (reason) => {
2786
- process.stderr.write(`[ait-local-debug] unhandledRejection: ${String(reason)}\n`);
3944
+ logError("tool.error", {
3945
+ msg: `unhandledRejection: ${String(reason)}`,
3946
+ errorKind: "unhandled-rejection",
3947
+ mode: "local"
3948
+ });
2787
3949
  shutdown();
2788
3950
  process.exit(1);
2789
3951
  });
@@ -2863,7 +4025,13 @@ var HttpAitSource = class {
2863
4025
  * }
2864
4026
  * }
2865
4027
  */
2866
- /** Tool descriptors served by the dev-mode server. */
4028
+ /**
4029
+ * Tool descriptors served by the dev-mode server.
4030
+ *
4031
+ * All dev-mode tools are Tier C (both envs) per RFC #277 — the dev-mode server
4032
+ * itself is the mock-side embodiment of those Tier C tools. `availableIn` is
4033
+ * declared so the surface stays consistent with the debug-mode registry.
4034
+ */
2867
4035
  const DEV_TOOL_DEFINITIONS = [
2868
4036
  {
2869
4037
  name: "AIT.getMockState",
@@ -2872,7 +4040,8 @@ const DEV_TOOL_DEFINITIONS = [
2872
4040
  type: "object",
2873
4041
  properties: {},
2874
4042
  required: []
2875
- }
4043
+ },
4044
+ availableIn: "both"
2876
4045
  },
2877
4046
  {
2878
4047
  name: "AIT.getOperationalEnvironment",
@@ -2881,7 +4050,8 @@ const DEV_TOOL_DEFINITIONS = [
2881
4050
  type: "object",
2882
4051
  properties: {},
2883
4052
  required: []
2884
- }
4053
+ },
4054
+ availableIn: "both"
2885
4055
  },
2886
4056
  {
2887
4057
  name: "AIT.getSdkCallHistory",
@@ -2890,7 +4060,8 @@ const DEV_TOOL_DEFINITIONS = [
2890
4060
  type: "object",
2891
4061
  properties: {},
2892
4062
  required: []
2893
- }
4063
+ },
4064
+ availableIn: "both"
2894
4065
  },
2895
4066
  {
2896
4067
  name: "devtools_get_mock_state",
@@ -2899,7 +4070,8 @@ const DEV_TOOL_DEFINITIONS = [
2899
4070
  type: "object",
2900
4071
  properties: {},
2901
4072
  required: []
2902
- }
4073
+ },
4074
+ availableIn: "both"
2903
4075
  }
2904
4076
  ];
2905
4077
  const DEV_TOOL_NAMES = new Set(DEV_TOOL_DEFINITIONS.map((t) => t.name));
@@ -2909,47 +4081,23 @@ function createDevServer(deps = {}) {
2909
4081
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
2910
4082
  const server = new Server({
2911
4083
  name: "ait-devtools",
2912
- version: "0.1.41"
4084
+ version: "0.1.44"
2913
4085
  }, { capabilities: { tools: {} } });
2914
4086
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
2915
4087
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2916
4088
  const name = request.params.name;
2917
- if (!DEV_TOOL_NAMES.has(name)) return {
2918
- content: [{
2919
- type: "text",
2920
- text: `Unknown tool: ${name}`
2921
- }],
2922
- isError: true
2923
- };
4089
+ if (!DEV_TOOL_NAMES.has(name)) return mcpError(`알 수 없는 tool: ${name}`);
2924
4090
  try {
2925
4091
  const effective = name === "devtools_get_mock_state" ? "AIT.getMockState" : name;
2926
- if (!isAitToolName(effective)) return {
2927
- content: [{
2928
- type: "text",
2929
- text: `Unknown tool: ${name}`
2930
- }],
2931
- isError: true
2932
- };
4092
+ if (!isAitToolName(effective)) return mcpError(`알 수 없는 tool: ${name}`);
2933
4093
  switch (effective) {
2934
4094
  case "AIT.getMockState": return jsonResult(await getMockState(aitSource));
2935
4095
  case "AIT.getOperationalEnvironment": return jsonResult(await getOperationalEnvironment(aitSource));
2936
4096
  case "AIT.getSdkCallHistory": return jsonResult(await getSdkCallHistory(aitSource));
2937
- default: return {
2938
- content: [{
2939
- type: "text",
2940
- text: `Unknown tool: ${name}`
2941
- }],
2942
- isError: true
2943
- };
4097
+ default: return mcpError(`알 수 없는 tool: ${name}`);
2944
4098
  }
2945
4099
  } catch (err) {
2946
- return {
2947
- content: [{
2948
- type: "text",
2949
- text: `${err instanceof Error ? err.message : String(err)}\nIs the Vite dev server running with the @ait-co/devtools unplugin option \`mcp: true\`? Is AIT_DEVTOOLS_URL set correctly?`
2950
- }],
2951
- isError: true
2952
- };
4100
+ return mcpError(`${name} 실패: ${err instanceof Error ? err.message : String(err)}\nVite dev 서버가 @ait-co/devtools unplugin \`mcp: true\` 옵션으로 실행 중인지 확인하세요. AIT_DEVTOOLS_URL 환경변수가 올바르게 설정됐는지도 확인하세요.`);
2953
4101
  }
2954
4102
  });
2955
4103
  return server;