@ait-co/devtools 0.1.43 → 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
@@ -53,6 +53,98 @@ var ChiiAitSource = class {
53
53
  }
54
54
  };
55
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
56
148
  //#region src/mcp/chii-connection.ts
57
149
  /**
58
150
  * Production `CdpConnection` backed by the local Chii relay.
@@ -176,7 +268,7 @@ var ChiiCdpConnection = class {
176
268
  }
177
269
  if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
178
270
  const prevId = this.activeTargetId;
179
- process.stderr.write(`[ait-debug] 이전 page 세션 종료 새 attach로 교체 (prev=${prevId})\n`);
271
+ logInfo("page.detached", { prevTargetId: prevId });
180
272
  this.evictTarget(prevId);
181
273
  }
182
274
  this.targets.clear();
@@ -824,6 +916,88 @@ function getEnvironmentReason(input = {}) {
824
916
  return "default-mock";
825
917
  }
826
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
827
1001
  //#region src/mcp/local-connection.ts
828
1002
  /**
829
1003
  * Local-browser `CdpConnection` — attaches directly to a Chromium instance
@@ -1427,6 +1601,15 @@ function removeLock(lockPath) {
1427
1601
  } catch {}
1428
1602
  }
1429
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
+ /**
1430
1613
  * Attempts to acquire the server lock.
1431
1614
  *
1432
1615
  * - If no lock exists (or the lock is stale): writes a new lock and returns a
@@ -1757,7 +1940,7 @@ const DEBUG_TOOL_DEFINITIONS = [
1757
1940
  },
1758
1941
  {
1759
1942
  name: "list_pages",
1760
- 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.",
1761
1944
  inputSchema: {
1762
1945
  type: "object",
1763
1946
  properties: {},
@@ -1903,6 +2086,19 @@ const DEBUG_TOOL_DEFINITIONS = [
1903
2086
  required: []
1904
2087
  },
1905
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"
1906
2102
  }
1907
2103
  ];
1908
2104
  const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
@@ -1946,7 +2142,11 @@ function filterToolsByEnvironment(tools, env) {
1946
2142
  * All other tools require an attached page (`enableDomains` must succeed) and
1947
2143
  * are only advertised in `tools/list` once a target appears.
1948
2144
  */
1949
- 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
+ ]);
1950
2150
  /** Renders a CDP `RemoteObject` console arg to a stable display string. */
1951
2151
  function renderRemoteObject(arg) {
1952
2152
  if (arg.value !== void 0) {
@@ -2058,7 +2258,7 @@ function listPages(connection, tunnel) {
2058
2258
  * the scheme authority which is in the caller's input, not ours to own).
2059
2259
  */
2060
2260
  function buildAttachUrl(schemeUrl, tunnel) {
2061
- 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 터널 상태를 다시 확인하세요.");
2062
2262
  const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
2063
2263
  return {
2064
2264
  attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
@@ -2173,8 +2373,9 @@ function isLaunchFailureStderr(stderr) {
2173
2373
  /**
2174
2374
  * 로컬 HTTP 서버 URL(`http://127.0.0.1:<port>/attach?u=...`)을 OS 기본 브라우저로 연다.
2175
2375
  *
2176
- * platform별 fallback chain으로 시도하며, 모두 실패해도 `opened: false` + `httpUrl`을
2177
- * 반환해 사용자가 직접 브라우저에 붙여넣을 있게 한다.
2376
+ * platform별 fallback chain으로 시도하며, 모두 실패하면 1회 retry를 수행한다
2377
+ * (ephemeral process launch 타이밍 문제 대응). retry까지 실패해도 `opened: false` +
2378
+ * `httpUrl`을 반환해 사용자가 직접 브라우저에 붙여넣을 수 있게 한다.
2178
2379
  *
2179
2380
  * SECRET-HANDLING:
2180
2381
  * - tmp 파일을 만들지 않는다 (HTML/PNG는 HTTP 서버가 메모리에서 응답).
@@ -2187,25 +2388,39 @@ function isLaunchFailureStderr(stderr) {
2187
2388
  */
2188
2389
  async function openQrInBrowser(httpUrl, pngUrl) {
2189
2390
  const { spawnSync } = await import("node:child_process");
2190
- const candidates = getBrowserCandidates(httpUrl);
2191
- const stderrLines = [];
2192
- for (const { cmd, args } of candidates) {
2193
- const result = spawnSync(cmd, args, {
2194
- encoding: "utf8",
2195
- timeout: 5e3
2196
- });
2197
- if (result.error) {
2198
- stderrLines.push(`${cmd}: ${result.error.message}`);
2199
- 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;
2200
2409
  }
2201
- const stderr = typeof result.stderr === "string" ? result.stderr : "";
2202
- if (stderr) stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);
2203
- if (result.status === 0 && !isLaunchFailureStderr(stderr)) return {
2204
- opened: true,
2205
- httpUrl,
2206
- pngUrl
2207
- };
2410
+ return false;
2208
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
+ };
2209
2424
  return {
2210
2425
  opened: false,
2211
2426
  httpUrl,
@@ -2451,7 +2666,7 @@ async function evaluate(connection, expression) {
2451
2666
  * any log or stderr by the caller.
2452
2667
  */
2453
2668
  function buildCallSdkExpression(name, args) {
2454
- 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)}); }})()`;
2455
2670
  }
2456
2671
  /**
2457
2672
  * Parses the JSON envelope string returned by the `call_sdk` expression.
@@ -2570,6 +2785,145 @@ function getMockState(source) {
2570
2785
  function getOperationalEnvironment(source) {
2571
2786
  return source.get("AIT.getOperationalEnvironment");
2572
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
+ }
2573
2927
  //#endregion
2574
2928
  //#region src/mcp/totp.ts
2575
2929
  /**
@@ -2660,6 +3014,15 @@ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
2660
3014
  * and would be stale by the time a human scans. The in-app deep-link builder
2661
3015
  * splices the live code at attach time.
2662
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
+ *
2663
3026
  * SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear
2664
3027
  * in any output from this module.
2665
3028
  *
@@ -2782,6 +3145,118 @@ async function printAttachBanner(input) {
2782
3145
  const banner = await renderAttachBanner(input);
2783
3146
  process.stderr.write(`${banner}\n`);
2784
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
+ }
2785
3260
  //#endregion
2786
3261
  //#region src/mcp/debug-server.ts
2787
3262
  /**
@@ -2899,12 +3374,13 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
2899
3374
  * naturally via `enableDomains`). The tier only controls visibility.
2900
3375
  */
2901
3376
  function createDebugServer(deps) {
2902
- const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep } = deps;
3377
+ const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep } = deps;
2903
3378
  const resolveEnvironment = getEnvDep ?? (() => getEnvironment({ connection }));
2904
3379
  const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({ connection }));
3380
+ const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
2905
3381
  const server = new Server({
2906
3382
  name: "ait-debug",
2907
- version: "0.1.43"
3383
+ version: "0.1.44"
2908
3384
  }, { capabilities: { tools: { listChanged: true } } });
2909
3385
  server.setRequestHandler(ListToolsRequestSchema, () => {
2910
3386
  const env = resolveEnvironment();
@@ -2923,15 +3399,16 @@ function createDebugServer(deps) {
2923
3399
  };
2924
3400
  const env = resolveEnvironment();
2925
3401
  if (!isToolAvailableIn(name, env)) {
2926
- const reason = `tool ${name} is available only in ${getToolAvailability(name)}. Current environment is ${env} (${resolveEnvironmentReason()}).`;
2927
- process.stderr.write(`[ait-debug] tier-filter rejected ${name}: ${reason}\n`);
2928
- return {
2929
- content: [{
2930
- type: "text",
2931
- text: reason
2932
- }],
2933
- isError: true
2934
- };
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);
2935
3412
  }
2936
3413
  if (isAitToolName(name)) try {
2937
3414
  await connection.enableDomains();
@@ -2944,19 +3421,31 @@ function createDebugServer(deps) {
2944
3421
  } catch (err) {
2945
3422
  return errorResult(err, name);
2946
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
+ }
2947
3439
  if (name === "build_attach_url") {
2948
3440
  const schemeUrl = request.params.arguments?.scheme_url;
2949
- if (typeof schemeUrl !== "string" || schemeUrl === "") return {
2950
- content: [{
2951
- type: "text",
2952
- text: "build_attach_url requires a non-empty scheme_url."
2953
- }],
2954
- isError: true
2955
- };
3441
+ if (typeof schemeUrl !== "string" || schemeUrl === "") return mcpError("build_attach_url: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요.");
2956
3442
  const waitForAttach = request.params.arguments?.wait_for_attach === true;
2957
3443
  const openInBrowser = request.params.arguments?.open_in_browser !== false;
2958
3444
  const deploymentId = extractDeploymentId(schemeUrl);
2959
- if (!deploymentId) process.stderr.write("[ait-debug] build_attach_url: no _deploymentId in scheme_url; matching on presence only\n");
3445
+ if (!deploymentId) logInfo("tool.call", {
3446
+ tool: "build_attach_url",
3447
+ msg: "no _deploymentId in scheme_url; matching on presence only"
3448
+ });
2960
3449
  /** Returns true when the page list satisfies the attach condition. */
2961
3450
  const isMatchingPage = (pages) => {
2962
3451
  if (pages.length === 0) return false;
@@ -2973,10 +3462,50 @@ function createDebugServer(deps) {
2973
3462
  const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
2974
3463
  const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
2975
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).";
2976
- 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) {
2977
3497
  const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
2978
3498
  if (browserResult.opened) {
2979
- 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}`;
2980
3509
  if (!waitForAttach) return { content: [{
2981
3510
  type: "text",
2982
3511
  text: shortText
@@ -3000,12 +3529,21 @@ function createDebugServer(deps) {
3000
3529
  text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
3001
3530
  }] };
3002
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
+ };
3003
3539
  const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
3004
- 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";
3005
3542
  const qr = await renderQr(attachUrl);
3006
3543
  const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
3007
3544
  attachUrl,
3008
- relayUrl
3545
+ relayUrl,
3546
+ openResult
3009
3547
  }, null, 2)}\n\n${qr}`;
3010
3548
  if (!waitForAttach) return { content: [{
3011
3549
  type: "text",
@@ -3064,20 +3602,13 @@ function createDebugServer(deps) {
3064
3602
  try {
3065
3603
  await connection.enableDomains();
3066
3604
  } catch (err) {
3067
- const message = err instanceof Error ? err.message : String(err);
3068
3605
  if (name === "list_pages") {
3069
3606
  if (connection instanceof ChiiCdpConnection) try {
3070
3607
  await connection.refreshTargets();
3071
3608
  } catch {}
3072
3609
  return jsonResult$1(listPages(connection, getTunnelStatus()));
3073
3610
  }
3074
- return {
3075
- content: [{
3076
- type: "text",
3077
- text: `${message}\nCall list_pages to confirm a mini-app has attached over the relay.`
3078
- }],
3079
- isError: true
3080
- };
3611
+ return classifyEnableDomainError(err, name);
3081
3612
  }
3082
3613
  try {
3083
3614
  switch (name) {
@@ -3105,26 +3636,16 @@ function createDebugServer(deps) {
3105
3636
  case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection, resolveEnvironment()));
3106
3637
  case "evaluate": {
3107
3638
  const expression = request.params.arguments?.expression;
3108
- if (typeof expression !== "string" || expression === "") return {
3109
- content: [{
3110
- type: "text",
3111
- text: "evaluate requires a non-empty expression."
3112
- }],
3113
- isError: true
3114
- };
3639
+ if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
3115
3640
  return jsonResult$1(await evaluate(connection, expression));
3116
3641
  }
3117
3642
  case "call_sdk": {
3118
3643
  const sdkName = request.params.arguments?.name;
3119
- if (typeof sdkName !== "string" || sdkName === "") return {
3120
- content: [{
3121
- type: "text",
3122
- text: "call_sdk requires a non-empty name."
3123
- }],
3124
- isError: true
3125
- };
3644
+ if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
3126
3645
  const rawArgs = request.params.arguments?.args;
3127
- 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);
3128
3649
  }
3129
3650
  default: return unknownTool(name);
3130
3651
  }
@@ -3141,33 +3662,29 @@ function jsonResult$1(value) {
3141
3662
  }] };
3142
3663
  }
3143
3664
  function unknownTool(name) {
3144
- return {
3145
- content: [{
3146
- type: "text",
3147
- text: `Unknown tool: ${name}`
3148
- }],
3149
- isError: true
3150
- };
3665
+ return mcpError(`알 수 없는 tool: ${name}`);
3151
3666
  }
3152
3667
  /**
3153
- * Detects whether an error is a relay/websocket disconnect error.
3154
- * These are distinguished from "no page attached yet" errors because they
3155
- * require enableDomains() to be called again (re-establish the websocket),
3156
- * not just waiting for a target to appear.
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 안내
3157
3674
  */
3158
- function isDisconnectError(err) {
3159
- if (!(err instanceof Error)) return false;
3160
- const msg = err.message;
3161
- return msg.includes("relay에 연결되어 있지 않습니다") || msg.includes("relay WebSocket") || msg.includes("replaced-by-new-attach") || msg.includes("Chii relay connection closed");
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);
3162
3681
  }
3682
+ /**
3683
+ * CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
3684
+ * debug-server 내부 try/catch 블록에서 공통으로 사용한다.
3685
+ */
3163
3686
  function errorResult(err, name) {
3164
- return {
3165
- content: [{
3166
- type: "text",
3167
- text: `${name} failed: ${err instanceof Error ? err.message : String(err)}${isDisconnectError(err) ? "\n\nrelay 연결이 끊겼습니다. list_pages → enableDomains() 재호출로 재연결하세요. 폰이 백그라운드로 내려갔거나 미니앱이 종료됐을 수 있습니다." : "\nCall list_pages to confirm a mini-app has attached over the relay."}`
3168
- }],
3169
- isError: true
3170
- };
3687
+ return classifyToolError(err, name);
3171
3688
  }
3172
3689
  /**
3173
3690
  * Starts a polling watcher that detects the first 0→N target transition on
@@ -3247,27 +3764,45 @@ async function runDebugServer(options = {}) {
3247
3764
  port: relayPort,
3248
3765
  verifyAuth
3249
3766
  });
3767
+ logInfo("server.start", {
3768
+ port: relay.port,
3769
+ totpEnabled
3770
+ });
3250
3771
  let tunnel = null;
3251
- let tunnelStatus = {
3252
- up: false,
3253
- wssUrl: null
3254
- };
3772
+ let tunnelStatus = makeTunnelStatus(false, null);
3255
3773
  generateAttachToken();
3774
+ let tunnelProbe = null;
3256
3775
  startQuickTunnel(relay.port).then((t) => {
3257
3776
  tunnel = t;
3258
- tunnelStatus = {
3259
- up: true,
3260
- wssUrl: t.wssUrl
3261
- };
3777
+ tunnelStatus = makeTunnelStatus(true, t.wssUrl);
3262
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
+ });
3263
3800
  return printAttachBanner({
3264
3801
  wssUrl: t.wssUrl,
3265
3802
  totpEnabled
3266
3803
  });
3267
3804
  }, (err) => {
3268
- const message = err instanceof Error ? err.message : String(err);
3269
- 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.
3270
- `);
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.` });
3271
3806
  });
3272
3807
  const connection = new ChiiCdpConnection({ relayBaseUrl: relay.baseUrl });
3273
3808
  const aitSource = new ChiiAitSource(connection);
@@ -3275,17 +3810,18 @@ async function runDebugServer(options = {}) {
3275
3810
  try {
3276
3811
  qrServer = await startQrHttpServer();
3277
3812
  } catch (err) {
3278
- const message = err instanceof Error ? err.message : String(err);
3279
- 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)}` });
3280
3814
  }
3281
3815
  const devtoolsOpener = new AutoDevtoolsOpener();
3816
+ const diagnosticsCollector = new InMemoryDiagnosticsCollector();
3282
3817
  const server = createDebugServer({
3283
3818
  connection,
3284
3819
  aitSource,
3285
3820
  getTunnelStatus: () => tunnelStatus,
3286
3821
  get qrHttpServer() {
3287
3822
  return qrServer;
3288
- }
3823
+ },
3824
+ diagnosticsCollector
3289
3825
  });
3290
3826
  const transport = new StdioServerTransport();
3291
3827
  let closed = false;
@@ -3294,6 +3830,7 @@ async function runDebugServer(options = {}) {
3294
3830
  if (closed) return;
3295
3831
  closed = true;
3296
3832
  attachWatcher?.stop();
3833
+ tunnelProbe?.stop();
3297
3834
  connection.close();
3298
3835
  tunnel?.stop();
3299
3836
  relay.close();
@@ -3308,22 +3845,30 @@ async function runDebugServer(options = {}) {
3308
3845
  if (!closed) {
3309
3846
  closed = true;
3310
3847
  attachWatcher?.stop();
3848
+ tunnelProbe?.stop();
3311
3849
  tunnel?.stop();
3312
3850
  lockHandle.release();
3313
3851
  }
3314
3852
  });
3315
3853
  process.on("uncaughtException", (err) => {
3316
- process.stderr.write(`[ait-debug] uncaughtException: ${String(err)}\n`);
3854
+ logError("tool.error", {
3855
+ msg: `uncaughtException: ${String(err)}`,
3856
+ errorKind: "uncaught"
3857
+ });
3317
3858
  shutdown();
3318
3859
  process.exit(1);
3319
3860
  });
3320
3861
  process.on("unhandledRejection", (reason) => {
3321
- process.stderr.write(`[ait-debug] unhandledRejection: ${String(reason)}\n`);
3862
+ logError("tool.error", {
3863
+ msg: `unhandledRejection: ${String(reason)}`,
3864
+ errorKind: "unhandled-rejection"
3865
+ });
3322
3866
  shutdown();
3323
3867
  process.exit(1);
3324
3868
  });
3325
3869
  await server.connect(transport);
3326
3870
  attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
3871
+ diagnosticsCollector.recordAttach();
3327
3872
  devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({ connection }));
3328
3873
  });
3329
3874
  }
@@ -3387,12 +3932,20 @@ async function runLocalDebugServer(options = {}) {
3387
3932
  }
3388
3933
  });
3389
3934
  process.on("uncaughtException", (err) => {
3390
- 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
+ });
3391
3940
  shutdown();
3392
3941
  process.exit(1);
3393
3942
  });
3394
3943
  process.on("unhandledRejection", (reason) => {
3395
- 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
+ });
3396
3949
  shutdown();
3397
3950
  process.exit(1);
3398
3951
  });
@@ -3528,47 +4081,23 @@ function createDevServer(deps = {}) {
3528
4081
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
3529
4082
  const server = new Server({
3530
4083
  name: "ait-devtools",
3531
- version: "0.1.43"
4084
+ version: "0.1.44"
3532
4085
  }, { capabilities: { tools: {} } });
3533
4086
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
3534
4087
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
3535
4088
  const name = request.params.name;
3536
- if (!DEV_TOOL_NAMES.has(name)) return {
3537
- content: [{
3538
- type: "text",
3539
- text: `Unknown tool: ${name}`
3540
- }],
3541
- isError: true
3542
- };
4089
+ if (!DEV_TOOL_NAMES.has(name)) return mcpError(`알 수 없는 tool: ${name}`);
3543
4090
  try {
3544
4091
  const effective = name === "devtools_get_mock_state" ? "AIT.getMockState" : name;
3545
- if (!isAitToolName(effective)) return {
3546
- content: [{
3547
- type: "text",
3548
- text: `Unknown tool: ${name}`
3549
- }],
3550
- isError: true
3551
- };
4092
+ if (!isAitToolName(effective)) return mcpError(`알 수 없는 tool: ${name}`);
3552
4093
  switch (effective) {
3553
4094
  case "AIT.getMockState": return jsonResult(await getMockState(aitSource));
3554
4095
  case "AIT.getOperationalEnvironment": return jsonResult(await getOperationalEnvironment(aitSource));
3555
4096
  case "AIT.getSdkCallHistory": return jsonResult(await getSdkCallHistory(aitSource));
3556
- default: return {
3557
- content: [{
3558
- type: "text",
3559
- text: `Unknown tool: ${name}`
3560
- }],
3561
- isError: true
3562
- };
4097
+ default: return mcpError(`알 수 없는 tool: ${name}`);
3563
4098
  }
3564
4099
  } catch (err) {
3565
- return {
3566
- content: [{
3567
- type: "text",
3568
- 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?`
3569
- }],
3570
- isError: true
3571
- };
4100
+ return mcpError(`${name} 실패: ${err instanceof Error ? err.message : String(err)}\nVite dev 서버가 @ait-co/devtools unplugin \`mcp: true\` 옵션으로 실행 중인지 확인하세요. AIT_DEVTOOLS_URL 환경변수가 올바르게 설정됐는지도 확인하세요.`);
3572
4101
  }
3573
4102
  });
3574
4103
  return server;