@ait-co/devtools 0.1.40 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { existsSync, realpathSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { argv } from "node:process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -11,27 +11,31 @@ 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
- function isObject$3(value) {
22
+ function isObject$4(value) {
19
23
  return typeof value === "object" && value !== null;
20
24
  }
21
25
  /** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
22
26
  function asSdkCallHistory(raw) {
23
- if (isObject$3(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
27
+ if (isObject$4(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
24
28
  return { calls: [] };
25
29
  }
26
30
  /** Narrows an `AIT.getMockState` response to an opaque record. */
27
31
  function asMockState(raw) {
28
- return isObject$3(raw) ? raw : {};
32
+ return isObject$4(raw) ? raw : {};
29
33
  }
30
34
  /** Narrows an `AIT.getOperationalEnvironment` response. */
31
35
  function asOperationalEnvironment(raw) {
32
36
  return {
33
- environment: isObject$3(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
34
- sdkVersion: isObject$3(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
37
+ environment: isObject$4(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
38
+ sdkVersion: isObject$4(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
35
39
  };
36
40
  }
37
41
  var ChiiAitSource = class {
@@ -62,10 +66,16 @@ var ChiiAitSource = class {
62
66
  * events in ring buffers the tool layer reads via `getBufferedEvents`.
63
67
  *
64
68
  * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
69
+ *
70
+ * Attach reliability (#281):
71
+ * `refreshTargets()` emits an internal 'target:attached' event whenever a
72
+ * new target is added to the relay. `waitForFirstTarget()` awaits that event
73
+ * (with a polling-interval fallback) so `build_attach_url wait_for_attach`
74
+ * resolves deterministically rather than racing between polling rounds.
65
75
  */
66
76
  /** Max events retained per domain ring buffer. */
67
77
  const DEFAULT_BUFFER_SIZE$1 = 500;
68
- function isObject$2(value) {
78
+ function isObject$3(value) {
69
79
  return typeof value === "object" && value !== null;
70
80
  }
71
81
  function parseInbound$1(raw) {
@@ -75,13 +85,13 @@ function parseInbound$1(raw) {
75
85
  } catch {
76
86
  return null;
77
87
  }
78
- if (!isObject$2(parsed)) return null;
88
+ if (!isObject$3(parsed)) return null;
79
89
  const message = {};
80
90
  if (typeof parsed.id === "number") message.id = parsed.id;
81
91
  if (typeof parsed.method === "string") message.method = parsed.method;
82
92
  if ("params" in parsed) message.params = parsed.params;
83
93
  if ("result" in parsed) message.result = parsed.result;
84
- if (isObject$2(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
94
+ if (isObject$3(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
85
95
  return message;
86
96
  }
87
97
  const PHASE_1_EVENTS$1 = [
@@ -90,25 +100,67 @@ const PHASE_1_EVENTS$1 = [
90
100
  "Network.responseReceived"
91
101
  ];
92
102
  /**
103
+ * Ring buffer size for `Runtime.exceptionThrown`.
104
+ *
105
+ * Exceptions are rarer than console messages but each is heavier (stack
106
+ * trace). 50 is generous enough to cover a crash scenario while keeping
107
+ * memory bounded.
108
+ *
109
+ * **Lifecycle note**: the exception buffer intentionally survives `replaced` /
110
+ * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
111
+ * transitions. Rationale: an exception fired just before a crash is exactly
112
+ * the signal we want to preserve for root-cause analysis. The buffer
113
+ * represents "exceptions seen in this MCP session", not "exceptions in the
114
+ * current page".
115
+ */
116
+ const EXCEPTION_BUFFER_SIZE = 50;
117
+ /** Default per-command timeout if neither option nor env var is set. */
118
+ const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
119
+ /**
93
120
  * Production CDP connection. Polls the relay for the first attached target,
94
121
  * opens a client websocket to it, enables Phase 1 domains, and buffers events.
95
122
  */
96
123
  var ChiiCdpConnection = class {
97
124
  relayBaseUrl;
98
125
  bufferSize;
126
+ commandTimeoutMs;
99
127
  emitter = new EventEmitter();
100
128
  buffers = /* @__PURE__ */ new Map();
101
129
  targets = /* @__PURE__ */ new Map();
102
130
  ws = null;
131
+ connectionState = "idle";
103
132
  nextCommandId = 1;
133
+ /**
134
+ * The single active target id under the single-attach model.
135
+ * Updated by `refreshTargets()` whenever a non-null target is present.
136
+ * Used to detect a new (different) target attach and evict the previous one.
137
+ */
138
+ activeTargetId = null;
104
139
  /** In-flight enableDomains() promise — concurrent callers share it. */
105
140
  enablingPromise = null;
106
141
  /** Pending request→response commands keyed by CDP message id. */
107
142
  pending = /* @__PURE__ */ new Map();
143
+ /**
144
+ * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
145
+ * or `null` if no crash has been detected since the last `enableDomains()`.
146
+ */
147
+ lastCrashDetectedAt = null;
148
+ /**
149
+ * Per-target last-seen timestamp (ms since epoch). Updated on any inbound
150
+ * CDP message carrying data from a target. Keyed by target id.
151
+ */
152
+ targetLastSeenAt = /* @__PURE__ */ new Map();
153
+ /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
154
+ heartbeatHandle = null;
155
+ /** Lifecycle event listeners (crash / destroyed / detached). */
156
+ lifecycleListeners = [];
108
157
  constructor(options) {
109
158
  this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
110
159
  this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE$1;
160
+ const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
161
+ this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
111
162
  for (const event of PHASE_1_EVENTS$1) this.buffers.set(event, []);
163
+ this.buffers.set("Runtime.exceptionThrown", []);
112
164
  this.emitter.setMaxListeners(0);
113
165
  }
114
166
  /** Refresh the attached-target list from the relay's `GET /targets`. */
@@ -116,22 +168,118 @@ var ChiiCdpConnection = class {
116
168
  const res = await fetch(`${this.relayBaseUrl}/targets`);
117
169
  if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
118
170
  const body = await res.json();
119
- const list = isObject$2(body) && Array.isArray(body.targets) ? body.targets : [];
171
+ const list = isObject$3(body) && Array.isArray(body.targets) ? body.targets : [];
172
+ let newestTargetId = null;
173
+ for (const item of list) {
174
+ if (!isObject$3(item) || typeof item.id !== "string") continue;
175
+ newestTargetId = item.id;
176
+ }
177
+ if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
178
+ const prevId = this.activeTargetId;
179
+ process.stderr.write(`[ait-debug] 이전 page 세션 종료 — 새 attach로 교체 (prev=${prevId})\n`);
180
+ this.evictTarget(prevId);
181
+ }
120
182
  this.targets.clear();
121
183
  for (const item of list) {
122
- if (!isObject$2(item) || typeof item.id !== "string") continue;
184
+ if (!isObject$3(item) || typeof item.id !== "string") continue;
185
+ if (item.id !== newestTargetId) continue;
123
186
  this.targets.set(item.id, {
124
187
  id: item.id,
125
188
  title: typeof item.title === "string" ? item.title : "",
126
189
  url: typeof item.url === "string" ? item.url : ""
127
190
  });
128
191
  }
129
- return [...this.targets.values()];
192
+ if (newestTargetId !== null) this.activeTargetId = newestTargetId;
193
+ else this.activeTargetId = null;
194
+ const result = [...this.targets.values()];
195
+ if (newestTargetId !== null) this.emitter.emit("target:attached", result);
196
+ return result;
130
197
  }
131
198
  listTargets() {
132
199
  return [...this.targets.values()];
133
200
  }
134
201
  /**
202
+ * Waits until at least one target matching `filterFn` is attached, then
203
+ * resolves with the full target list at that moment.
204
+ *
205
+ * Resolution happens on whichever comes first:
206
+ * (a) a `'target:attached'` event from `refreshTargets()` (triggered by
207
+ * the /targets poll finding a new target), OR
208
+ * (b) a `'target:attached'` event from `handleMessage()` (triggered by
209
+ * the first inbound CDP message from a target — confirms the relay
210
+ * websocket has data from the phone, not just a target entry in the map).
211
+ *
212
+ * This dual-signal approach eliminates the polling race that previously
213
+ * caused `wait_for_attach` to resolve before the first CDP message arrived.
214
+ *
215
+ * Falls back to checking `listTargets()` every `pollIntervalMs` in case the
216
+ * EventEmitter is missed (defensive belt-and-suspenders).
217
+ *
218
+ * @param filterFn - Predicate that the returned targets must satisfy.
219
+ * @param timeoutMs - Reject after this many ms (default 90 000).
220
+ * @param pollIntervalMs - Fallback poll interval (default 500ms).
221
+ */
222
+ waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
223
+ const current = this.listTargets();
224
+ if (filterFn(current)) return Promise.resolve(current);
225
+ return new Promise((resolve, reject) => {
226
+ let settled = false;
227
+ let pollHandle = null;
228
+ const settle = (targets) => {
229
+ if (settled) return;
230
+ settled = true;
231
+ clearTimeout(timeoutHandle);
232
+ if (pollHandle !== null) {
233
+ clearInterval(pollHandle);
234
+ pollHandle = null;
235
+ }
236
+ this.emitter.off("target:attached", onAttach);
237
+ resolve(targets);
238
+ };
239
+ const onAttach = (targets) => {
240
+ if (filterFn(targets)) settle(targets);
241
+ };
242
+ const timeoutHandle = setTimeout(() => {
243
+ if (settled) return;
244
+ settled = true;
245
+ if (pollHandle !== null) {
246
+ clearInterval(pollHandle);
247
+ pollHandle = null;
248
+ }
249
+ this.emitter.off("target:attached", onAttach);
250
+ reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
251
+ }, timeoutMs);
252
+ this.emitter.on("target:attached", onAttach);
253
+ pollHandle = setInterval(() => {
254
+ this.refreshTargets().then((targets) => {
255
+ if (filterFn(targets)) settle(targets);
256
+ }, () => {});
257
+ }, pollIntervalMs);
258
+ });
259
+ }
260
+ /**
261
+ * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
262
+ * detected since the last `enableDomains()` call, or `null` if none.
263
+ */
264
+ getLastCrashDetectedAt() {
265
+ return this.lastCrashDetectedAt;
266
+ }
267
+ /**
268
+ * Last-seen timestamp (ms since epoch) for a given target id, or `null` if
269
+ * the target is unknown / no message has been received from it yet.
270
+ */
271
+ getTargetLastSeenAt(targetId) {
272
+ return this.targetLastSeenAt.get(targetId) ?? null;
273
+ }
274
+ /** Subscribe to target lifecycle events (crash / destroyed / detached). */
275
+ onLifecycle(listener) {
276
+ this.lifecycleListeners.push(listener);
277
+ return () => {
278
+ const idx = this.lifecycleListeners.indexOf(listener);
279
+ if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
280
+ };
281
+ }
282
+ /**
135
283
  * Connect a client websocket to the first attached target and enable Phase 1
136
284
  * domains. Resolves once the socket is open and enable commands are sent.
137
285
  */
@@ -152,11 +300,19 @@ var ChiiCdpConnection = class {
152
300
  ws.once("open", () => resolve());
153
301
  ws.once("error", (err) => reject(err));
154
302
  });
303
+ this.lastCrashDetectedAt = null;
304
+ this.targetLastSeenAt.clear();
305
+ this.connectionState = "connected";
155
306
  ws.on("message", (data) => this.handleMessage(data.toString()));
307
+ ws.on("close", () => this.handleDisconnect("relay WebSocket 연결이 끊겼습니다"));
308
+ ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
156
309
  this.sendFireAndForget("Runtime.enable");
157
310
  this.sendFireAndForget("Network.enable");
158
311
  this.sendFireAndForget("DOM.enable");
159
312
  this.sendFireAndForget("Page.enable");
313
+ this.sendFireAndForget("Inspector.enable");
314
+ this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
315
+ this.startHeartbeat(target.id);
160
316
  }
161
317
  /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
162
318
  sendFireAndForget(method, params = {}) {
@@ -179,15 +335,35 @@ var ChiiCdpConnection = class {
179
335
  * Issue an arbitrary request→response command over the relay and resolve with
180
336
  * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
181
337
  * `AIT.*` methods, forwarded over the same Chii channel) build on this.
338
+ *
339
+ * Rejects immediately if the connection is disconnected (fail-fast — no
340
+ * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
341
+ * reattach.
342
+ *
343
+ * Times out after `commandTimeoutMs` (default 30s, env
344
+ * `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
345
+ * and the promise rejects with a descriptive Korean error.
182
346
  */
183
347
  sendCommand(method, params = {}) {
348
+ if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
184
349
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No mini-app page attached to the Chii relay yet. Call enableDomains() first."));
185
350
  const id = this.nextCommandId++;
186
351
  const ws = this.ws;
352
+ const timeoutMs = this.commandTimeoutMs;
187
353
  return new Promise((resolve, reject) => {
354
+ const handle = setTimeout(() => {
355
+ this.pending.delete(id);
356
+ reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
357
+ }, timeoutMs);
188
358
  this.pending.set(id, {
189
- resolve,
190
- reject
359
+ resolve: (v) => {
360
+ clearTimeout(handle);
361
+ resolve(v);
362
+ },
363
+ reject: (e) => {
364
+ clearTimeout(handle);
365
+ reject(e);
366
+ }
191
367
  });
192
368
  ws.send(JSON.stringify({
193
369
  id,
@@ -196,6 +372,117 @@ var ChiiCdpConnection = class {
196
372
  }));
197
373
  });
198
374
  }
375
+ /**
376
+ * Called on WebSocket `close` or `error` after a successful connection.
377
+ * Rejects all pending commands and marks the connection as disconnected so
378
+ * subsequent `sendCommand` calls fail fast (no auto-reconnect).
379
+ */
380
+ handleDisconnect(reason) {
381
+ if (this.connectionState === "disconnected") return;
382
+ this.connectionState = "disconnected";
383
+ this.ws = null;
384
+ this.stopHeartbeat();
385
+ const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
386
+ for (const waiter of this.pending.values()) waiter.reject(err);
387
+ this.pending.clear();
388
+ }
389
+ /**
390
+ * Evict a previously active target under the single-attach model.
391
+ * Rejects pending commands with a 'replaced-by-new-attach' reason and emits
392
+ * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
393
+ * targetId. The caller is responsible for rebuilding the targets map afterwards.
394
+ *
395
+ * The error message uses 'replaced-by-new-attach' so test assertions can match it.
396
+ */
397
+ evictTarget(targetId) {
398
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
399
+ this.targets.delete(targetId);
400
+ this.targetLastSeenAt.delete(targetId);
401
+ const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
402
+ for (const waiter of this.pending.values()) waiter.reject(err);
403
+ this.pending.clear();
404
+ const event = {
405
+ kind: "replaced",
406
+ targetId,
407
+ detectedAt
408
+ };
409
+ for (const listener of this.lifecycleListeners) try {
410
+ listener(event);
411
+ } catch {}
412
+ }
413
+ /**
414
+ * Handle a page-level crash or target destruction event.
415
+ * Removes the target from the in-memory map, rejects all pending commands,
416
+ * and emits a lifecycle event.
417
+ *
418
+ * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
419
+ * @param targetId - The target ID from the event params (may be null for
420
+ * Inspector.targetCrashed which has no targetId in the params).
421
+ */
422
+ handleTargetGone(kind, targetId) {
423
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
424
+ this.lastCrashDetectedAt = Date.now();
425
+ if (targetId !== null) {
426
+ this.targets.delete(targetId);
427
+ this.targetLastSeenAt.delete(targetId);
428
+ if (this.activeTargetId === targetId) this.activeTargetId = null;
429
+ } else {
430
+ this.targets.clear();
431
+ this.targetLastSeenAt.clear();
432
+ this.activeTargetId = null;
433
+ }
434
+ const err = /* @__PURE__ */ new Error(`[ait-debug] ${kind === "crashed" ? "page crash (Inspector.targetCrashed)" : kind === "destroyed" ? "target 종료 (Target.targetDestroyed)" : "target detach (Target.detachedFromTarget)"} 감지됨 — relay에서 제거됐습니다. 새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).`);
435
+ for (const waiter of this.pending.values()) waiter.reject(err);
436
+ this.pending.clear();
437
+ const event = {
438
+ kind,
439
+ targetId,
440
+ detectedAt
441
+ };
442
+ for (const listener of this.lifecycleListeners) try {
443
+ listener(event);
444
+ } catch {}
445
+ }
446
+ /**
447
+ * Start the optional CDP heartbeat loop.
448
+ *
449
+ * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
450
+ * we send `Runtime.evaluate({expression: '1'})` to each active target. If
451
+ * the command times out (2 s hard deadline) or errors, we treat the target
452
+ * as dead and call `handleTargetGone`.
453
+ *
454
+ * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
455
+ * even when the phone app has crashed, so the ws-level disconnect (#252) won't
456
+ * fire. The heartbeat catches this gap.
457
+ *
458
+ * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
459
+ */
460
+ startHeartbeat(initialTargetId) {
461
+ this.stopHeartbeat();
462
+ const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
463
+ if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
464
+ const PING_TIMEOUT_MS = 2e3;
465
+ this.heartbeatHandle = setInterval(() => {
466
+ const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
467
+ for (const targetId of targetIds) {
468
+ const pingPromise = this.sendCommand("Runtime.evaluate", {
469
+ expression: "1",
470
+ returnByValue: true,
471
+ timeout: PING_TIMEOUT_MS
472
+ });
473
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
474
+ Promise.race([pingPromise, timeoutPromise]).catch(() => {
475
+ if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
476
+ });
477
+ }
478
+ }, envMs);
479
+ }
480
+ stopHeartbeat() {
481
+ if (this.heartbeatHandle !== null) {
482
+ clearInterval(this.heartbeatHandle);
483
+ this.heartbeatHandle = null;
484
+ }
485
+ }
199
486
  handleMessage(raw) {
200
487
  const message = parseInbound$1(raw);
201
488
  if (!message) return;
@@ -206,13 +493,35 @@ var ChiiCdpConnection = class {
206
493
  else waiter.resolve(message.result);
207
494
  return;
208
495
  }
496
+ const now = Date.now();
497
+ let firstMessageSeen = false;
498
+ for (const targetId of this.targets.keys()) {
499
+ if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
500
+ this.targetLastSeenAt.set(targetId, now);
501
+ }
502
+ if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
209
503
  if (typeof message.method !== "string") return;
504
+ if (message.method === "Inspector.targetCrashed") {
505
+ this.handleTargetGone("crashed", null);
506
+ return;
507
+ }
508
+ if (message.method === "Target.targetDestroyed") {
509
+ const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
510
+ this.handleTargetGone("destroyed", targetId);
511
+ return;
512
+ }
513
+ if (message.method === "Target.detachedFromTarget") {
514
+ const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
515
+ this.handleTargetGone("detached", targetId);
516
+ return;
517
+ }
210
518
  if (!this.buffers.has(message.method)) return;
211
519
  const event = message.method;
212
520
  const buffer = this.buffers.get(event);
213
521
  if (!buffer) return;
214
522
  buffer.push(message.params);
215
- if (buffer.length > this.bufferSize) buffer.shift();
523
+ const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
524
+ if (buffer.length > cap) buffer.shift();
216
525
  this.emitter.emit(event, message.params);
217
526
  }
218
527
  getBufferedEvents(event) {
@@ -224,10 +533,10 @@ var ChiiCdpConnection = class {
224
533
  }
225
534
  /** Close the relay client websocket and reject any in-flight commands. */
226
535
  close() {
227
- this.ws?.close();
228
- this.ws = null;
229
- for (const waiter of this.pending.values()) waiter.reject(/* @__PURE__ */ new Error("Chii relay connection closed."));
230
- this.pending.clear();
536
+ const ws = this.ws;
537
+ this.stopHeartbeat();
538
+ this.handleDisconnect("Chii relay connection closed");
539
+ ws?.close();
231
540
  }
232
541
  };
233
542
  //#endregion
@@ -260,9 +569,9 @@ var ChiiCdpConnection = class {
260
569
  * in any log, error message, or process output. `verifyAuth` is a black-box
261
570
  * predicate from the caller's perspective; this module only forwards pass/fail.
262
571
  */
263
- const require = createRequire(import.meta.url);
572
+ const require$1 = createRequire(import.meta.url);
264
573
  function loadChiiServer() {
265
- const mod = require("chii");
574
+ const mod = require$1("chii");
266
575
  if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
267
576
  throw new Error("chii server module did not expose start()");
268
577
  }
@@ -315,6 +624,206 @@ async function startChiiRelay(options = {}) {
315
624
  };
316
625
  }
317
626
  //#endregion
627
+ //#region src/mcp/devtools-opener.ts
628
+ /**
629
+ * Base URL for the Chrome DevTools inspector hosted on appspot.
630
+ *
631
+ * The `@` path segment is the "latest / bleeding edge" alias which tracks the
632
+ * current Chrome stable CDP protocol version — compatible with the chobitsu-
633
+ * based CDP that Chii injects. A specific commit hash may be pinned here if
634
+ * a regression is observed.
635
+ */
636
+ const DEVTOOLS_FRONTEND_BASE = "https://chrome-devtools-frontend.appspot.com/serve_file/@/inspector.html";
637
+ /**
638
+ * Assembles the Chrome DevTools inspector URL that connects to a Chii relay
639
+ * WebSocket.
640
+ *
641
+ * The `wss=` parameter expects a host-and-path string without the `wss://`
642
+ * scheme prefix — the DevTools frontend prepends it automatically.
643
+ *
644
+ * @param wssRelayUrl - Full `wss://` URL of the Chii relay (public tunnel).
645
+ * Example: `wss://abc.trycloudflare.com`
646
+ * @param panel - Initial panel. Defaults to `"console"`.
647
+ *
648
+ * @example
649
+ * buildChromeDevtoolsUrl('wss://abc.trycloudflare.com')
650
+ * // → 'https://chrome-devtools-frontend.appspot.com/serve_file/@/inspector.html?wss=abc.trycloudflare.com&panel=console'
651
+ */
652
+ function buildChromeDevtoolsUrl(wssRelayUrl, panel = "console") {
653
+ const wssParam = wssRelayUrl.replace(/^wss:\/\//i, "");
654
+ return `${DEVTOOLS_FRONTEND_BASE}?${new URLSearchParams({
655
+ wss: wssParam,
656
+ panel
657
+ }).toString()}`;
658
+ }
659
+ /**
660
+ * Returns `true` when auto-open is **disabled** via the `AIT_AUTO_DEVTOOLS`
661
+ * env var. Only the explicit `"0"` value disables it; anything else (including
662
+ * absent) leaves auto-open enabled.
663
+ */
664
+ function isAutoDevtoolsDisabled() {
665
+ return process.env.AIT_AUTO_DEVTOOLS === "0";
666
+ }
667
+ /**
668
+ * Opens the given URL in the OS default browser using a platform-appropriate
669
+ * command. Returns `true` on success.
670
+ *
671
+ * Failures are silent from the caller's perspective — the caller should log
672
+ * the URL to stderr as a fallback before calling this function.
673
+ */
674
+ function openUrlInBrowser(url) {
675
+ if (process.env.AIT_AUTO_DEVTOOLS_TEST_SKIP_SPAWN === "1") return false;
676
+ const { spawnSync } = __require("node:child_process");
677
+ const platform = process.platform;
678
+ let candidates;
679
+ if (platform === "darwin") candidates = [{
680
+ cmd: "open",
681
+ args: [url]
682
+ }];
683
+ else if (platform === "win32") candidates = [{
684
+ cmd: "cmd",
685
+ args: [
686
+ "/c",
687
+ "start",
688
+ "",
689
+ url
690
+ ]
691
+ }];
692
+ else candidates = [
693
+ {
694
+ cmd: "xdg-open",
695
+ args: [url]
696
+ },
697
+ {
698
+ cmd: "sensible-browser",
699
+ args: [url]
700
+ },
701
+ {
702
+ cmd: "x-www-browser",
703
+ args: [url]
704
+ }
705
+ ];
706
+ for (const { cmd, args } of candidates) try {
707
+ const result = spawnSync(cmd, args, {
708
+ encoding: "utf8",
709
+ timeout: 5e3
710
+ });
711
+ if (!result.error && result.status === 0) return true;
712
+ } catch {}
713
+ return false;
714
+ }
715
+ /**
716
+ * Manages auto-opening Chrome DevTools exactly once per relay attach session.
717
+ *
718
+ * Create one instance per `runDebugServer` call and pass its `open()` method
719
+ * as the `onFirstAttach` callback to `startAttachWatcher`.
720
+ *
721
+ * The open fires at most once. Subsequent `open()` calls are no-ops.
722
+ * Opt-out and mock-environment guard are checked at call time.
723
+ */
724
+ var AutoDevtoolsOpener = class {
725
+ _opened = false;
726
+ /**
727
+ * Attempts to auto-open Chrome DevTools.
728
+ *
729
+ * No-op when any of the following conditions hold:
730
+ * 1. Already opened this session (`_opened` is true).
731
+ * 2. `AIT_AUTO_DEVTOOLS=0` opt-out is set.
732
+ * 3. Environment is `mock` (env 1 — F12 is already available).
733
+ * 4. `wssRelayUrl` is null/undefined/empty (tunnel not yet up).
734
+ *
735
+ * Always writes the DevTools URL to stderr so the developer can copy it
736
+ * if the browser open fails or the popup is blocked.
737
+ *
738
+ * @param wssRelayUrl - The public `wss://` relay URL (from tunnel status).
739
+ * @param env - Current MCP environment (`mock` | `relay`).
740
+ */
741
+ open(wssRelayUrl, env) {
742
+ if (this._opened) return;
743
+ if (isAutoDevtoolsDisabled()) return;
744
+ if (env === "mock") return;
745
+ if (!wssRelayUrl) return;
746
+ this._opened = true;
747
+ const devtoolsUrl = buildChromeDevtoolsUrl(wssRelayUrl);
748
+ process.stderr.write(`[ait-debug] 기기가 연결됐습니다 — Chrome DevTools를 자동으로 엽니다.
749
+ [ait-debug] Chrome DevTools URL: ${devtoolsUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)
750
+ `);
751
+ if (!openUrlInBrowser(devtoolsUrl)) process.stderr.write("[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\n");
752
+ }
753
+ /** Returns `true` if `open()` has passed all guards and fired once. */
754
+ get opened() {
755
+ return this._opened;
756
+ }
757
+ };
758
+ //#endregion
759
+ //#region src/mcp/environment.ts
760
+ /**
761
+ * URL patterns that mark a CDP target as a real-device WebView relay.
762
+ *
763
+ * - `intoss-private://` is the Toss in-app private scheme — only ever observed
764
+ * inside the real Toss app WebView.
765
+ * - `*.trycloudflare.com` (host suffix) is the cloudflared quick tunnel used as
766
+ * the relay transport. A target whose URL is on that host is, by construction,
767
+ * reached over the relay.
768
+ *
769
+ * Pattern-only matches — no specific tunnel host or deploymentId is hard-coded.
770
+ */
771
+ const RELAY_URL_PATTERNS = [/^intoss-private:\/\//i, /:\/\/[a-z0-9-]+\.trycloudflare\.com(\/|$|:|\?)/i];
772
+ /**
773
+ * Returns true when the URL string looks like a real-device WebView attached
774
+ * over the Chii relay. Used for `getEnvironment()` precedence step 2.
775
+ */
776
+ function isRelayUrl(url) {
777
+ if (typeof url !== "string" || url.length === 0) return false;
778
+ return RELAY_URL_PATTERNS.some((p) => p.test(url));
779
+ }
780
+ /**
781
+ * Test/override hook — when non-null, `getEnvironment()` returns this value
782
+ * regardless of env vars or connection state. Cleared with `null`.
783
+ */
784
+ let envOverride = null;
785
+ /** Parses the `MCP_ENV` env var into a `McpEnvironment` if valid. */
786
+ function readEnvVar() {
787
+ const raw = process.env.MCP_ENV;
788
+ if (raw === "mock" || raw === "relay") return raw;
789
+ }
790
+ /**
791
+ * Returns the current MCP environment, applying the precedence rules:
792
+ * 1. test override (if set)
793
+ * 2. `MCP_ENV` env var
794
+ * 3. CDP target URL pattern match
795
+ * 4. default `mock`
796
+ */
797
+ function getEnvironment(input = {}) {
798
+ if (envOverride !== null) return envOverride;
799
+ const fromEnv = readEnvVar();
800
+ if (fromEnv !== void 0) return fromEnv;
801
+ const { connection } = input;
802
+ if (connection !== void 0) {
803
+ const targets = connection.listTargets();
804
+ for (const t of targets) if (isRelayUrl(t.url)) return "relay";
805
+ }
806
+ return "mock";
807
+ }
808
+ /**
809
+ * Returns the `EnvironmentReason` that drove the current `getEnvironment()`
810
+ * result. Used by stderr logs and the rejection-reason payload on Tier A/B
811
+ * mismatch errors. SECRET-HANDLING: only stable enum strings — no URL or
812
+ * secret value is ever returned.
813
+ */
814
+ function getEnvironmentReason(input = {}) {
815
+ if (envOverride !== null) return envOverride === "mock" ? "env-var-mock" : "env-var-relay";
816
+ const fromEnv = readEnvVar();
817
+ if (fromEnv === "mock") return "env-var-mock";
818
+ if (fromEnv === "relay") return "env-var-relay";
819
+ const { connection } = input;
820
+ if (connection !== void 0) {
821
+ const targets = connection.listTargets();
822
+ for (const t of targets) if (isRelayUrl(t.url)) return "cdp-target-url-relay-pattern";
823
+ }
824
+ return "default-mock";
825
+ }
826
+ //#endregion
318
827
  //#region src/mcp/local-connection.ts
319
828
  /**
320
829
  * Local-browser `CdpConnection` — attaches directly to a Chromium instance
@@ -335,7 +844,7 @@ async function startChiiRelay(options = {}) {
335
844
  */
336
845
  /** Max events retained per domain ring buffer. */
337
846
  const DEFAULT_BUFFER_SIZE = 500;
338
- function isObject$1(value) {
847
+ function isObject$2(value) {
339
848
  return typeof value === "object" && value !== null;
340
849
  }
341
850
  function parseInbound(raw) {
@@ -345,13 +854,13 @@ function parseInbound(raw) {
345
854
  } catch {
346
855
  return null;
347
856
  }
348
- if (!isObject$1(parsed)) return null;
857
+ if (!isObject$2(parsed)) return null;
349
858
  const message = {};
350
859
  if (typeof parsed.id === "number") message.id = parsed.id;
351
860
  if (typeof parsed.method === "string") message.method = parsed.method;
352
861
  if ("params" in parsed) message.params = parsed.params;
353
862
  if ("result" in parsed) message.result = parsed.result;
354
- if (isObject$1(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
863
+ if (isObject$2(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
355
864
  return message;
356
865
  }
357
866
  const PHASE_1_EVENTS = [
@@ -404,7 +913,7 @@ var LocalCdpConnection = class {
404
913
  this.targets.clear();
405
914
  let selected = null;
406
915
  for (const item of list) {
407
- if (!isObject$1(item) || typeof item.id !== "string") continue;
916
+ if (!isObject$2(item) || typeof item.id !== "string") continue;
408
917
  const cdpTarget = {
409
918
  id: item.id,
410
919
  title: typeof item.title === "string" ? item.title : "",
@@ -816,6 +1325,145 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl) {
816
1325
  </html>`;
817
1326
  }
818
1327
  //#endregion
1328
+ //#region src/mcp/server-lock.ts
1329
+ /**
1330
+ * Single debug session lock for the `devtools-mcp` debug server.
1331
+ *
1332
+ * At most one debug server process should run on a given machine at a time —
1333
+ * multiple concurrent instances create duplicate cloudflared tunnels, waste
1334
+ * resources, and confuse the user about which wssUrl to use.
1335
+ *
1336
+ * ## Lock file
1337
+ *
1338
+ * Location: `~/.ait-devtools/server.lock`
1339
+ *
1340
+ * Schema (JSON):
1341
+ * ```json
1342
+ * { "pid": 12345, "wssUrl": "wss://xxx.trycloudflare.com", "startedAt": "2026-01-01T00:00:00.000Z" }
1343
+ * ```
1344
+ *
1345
+ * ## Behaviour
1346
+ *
1347
+ * - **Acquire**: write PID + wssUrl + startedAt. Returns a `release()` handle.
1348
+ * - **Stale lock recovery**: if the stored PID is no longer alive
1349
+ * (`process.kill(pid, 0)` throws ESRCH), the lock is silently replaced.
1350
+ * - **Live conflict (option B)**: if the stored PID is alive, `acquireLock`
1351
+ * throws `ServerLockConflictError` with the existing PID and wssUrl so the
1352
+ * caller can surface a clear message to the agent.
1353
+ * - **Release**: remove the lock file. Called on graceful shutdown (SIGINT /
1354
+ * SIGTERM / SIGHUP). SIGKILL survivors leave a stale file — the next startup
1355
+ * recovers it automatically via the alive check.
1356
+ *
1357
+ * ## wssUrl update
1358
+ *
1359
+ * The lock is written before cloudflared starts, so `wssUrl` begins as `null`
1360
+ * and is updated in place once the tunnel URL is known via `updateWssUrl`.
1361
+ *
1362
+ * Node-only.
1363
+ */
1364
+ /** Thrown when a live server process already holds the lock. */
1365
+ var ServerLockConflictError = class extends Error {
1366
+ /** PID of the existing server process. */
1367
+ existingPid;
1368
+ /** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */
1369
+ existingWssUrl;
1370
+ constructor(existingPid, existingWssUrl) {
1371
+ const urlNote = existingWssUrl != null ? ` relay URL: ${existingWssUrl}\n` : " relay URL: (tunnel still starting — retry in a moment)\n";
1372
+ super(`A debug server is already running (PID ${existingPid}).\n` + urlNote + `Stop the existing session before starting a new one.
1373
+ If it is already stopped but this error persists, remove the lock file:
1374
+ rm "${lockFilePath()}"`);
1375
+ this.name = "ServerLockConflictError";
1376
+ this.existingPid = existingPid;
1377
+ this.existingWssUrl = existingWssUrl;
1378
+ }
1379
+ };
1380
+ /** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */
1381
+ function lockFilePath() {
1382
+ return join(process.env.AIT_DEVTOOLS_LOCK_DIR ?? join(homedir(), ".ait-devtools"), "server.lock");
1383
+ }
1384
+ function ensureLockDir(lockPath) {
1385
+ mkdirSync(join(lockPath, ".."), { recursive: true });
1386
+ }
1387
+ /**
1388
+ * Returns `true` when the given PID refers to a running process.
1389
+ *
1390
+ * Uses `process.kill(pid, 0)` — a no-op signal that succeeds when the process
1391
+ * exists and we have permission to signal it; throws ESRCH when it doesn't exist.
1392
+ */
1393
+ function isPidAlive(pid) {
1394
+ try {
1395
+ process.kill(pid, 0);
1396
+ return true;
1397
+ } catch (err) {
1398
+ if (err.code === "EPERM") return true;
1399
+ return false;
1400
+ }
1401
+ }
1402
+ function readLock(lockPath) {
1403
+ if (!existsSync(lockPath)) return null;
1404
+ try {
1405
+ const raw = readFileSync(lockPath, "utf8");
1406
+ const parsed = JSON.parse(raw);
1407
+ if (typeof parsed === "object" && parsed !== null && "pid" in parsed && typeof parsed.pid === "number" && "startedAt" in parsed && typeof parsed.startedAt === "string") {
1408
+ const p = parsed;
1409
+ return {
1410
+ pid: p.pid,
1411
+ wssUrl: typeof p.wssUrl === "string" ? p.wssUrl : null,
1412
+ startedAt: p.startedAt
1413
+ };
1414
+ }
1415
+ return null;
1416
+ } catch {
1417
+ return null;
1418
+ }
1419
+ }
1420
+ function writeLock(lockPath, data) {
1421
+ ensureLockDir(lockPath);
1422
+ writeFileSync(lockPath, JSON.stringify(data, null, 2), { encoding: "utf8" });
1423
+ }
1424
+ function removeLock(lockPath) {
1425
+ try {
1426
+ rmSync(lockPath);
1427
+ } catch {}
1428
+ }
1429
+ /**
1430
+ * Attempts to acquire the server lock.
1431
+ *
1432
+ * - If no lock exists (or the lock is stale): writes a new lock and returns a
1433
+ * `LockHandle` with `updateWssUrl` + `release`.
1434
+ * - If a live process holds the lock: throws `ServerLockConflictError`.
1435
+ *
1436
+ * The initial `wssUrl` in the lock file is `null` — call
1437
+ * `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.
1438
+ */
1439
+ function acquireLock() {
1440
+ const lockPath = lockFilePath();
1441
+ const existing = readLock(lockPath);
1442
+ if (existing !== null) {
1443
+ if (isPidAlive(existing.pid)) throw new ServerLockConflictError(existing.pid, existing.wssUrl);
1444
+ process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
1445
+ }
1446
+ const data = {
1447
+ pid: process.pid,
1448
+ wssUrl: null,
1449
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1450
+ };
1451
+ writeLock(lockPath, data);
1452
+ let released = false;
1453
+ return {
1454
+ updateWssUrl(wssUrl) {
1455
+ if (released) return;
1456
+ data.wssUrl = wssUrl;
1457
+ writeLock(lockPath, data);
1458
+ },
1459
+ release() {
1460
+ if (released) return;
1461
+ released = true;
1462
+ removeLock(lockPath);
1463
+ }
1464
+ };
1465
+ }
1466
+ //#endregion
819
1467
  //#region src/mcp/deeplink.ts
820
1468
  /**
821
1469
  * Build a self-attaching dogfood deep link.
@@ -928,6 +1576,162 @@ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
928
1576
  return `${base}?${query}${hash}`;
929
1577
  }
930
1578
  //#endregion
1579
+ //#region src/mcp/sdk-signatures.ts
1580
+ function isObject$1(v) {
1581
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1582
+ }
1583
+ function describeArgs(args) {
1584
+ try {
1585
+ return JSON.stringify(args);
1586
+ } catch {
1587
+ return String(args);
1588
+ }
1589
+ }
1590
+ /**
1591
+ * 등록된 메서드 목록.
1592
+ *
1593
+ * 시그니처 출처 확인:
1594
+ * - 함수가 인자를 받지 않으면 args[0] 없음 → `args.length === 0`을 체크하지 않고
1595
+ * 그냥 통과시킨다(args 무시하는 stub가 많아서 noArgs 체크가 noise).
1596
+ * - 실 SDK 시그니처는 `src/__typecheck.ts`의 `Assert<Mock, Original>` 줄로 보장.
1597
+ */
1598
+ const SIGNATURES = [
1599
+ {
1600
+ name: "setDeviceOrientation",
1601
+ validateArgs(args) {
1602
+ const arg = args[0];
1603
+ if (!isObject$1(arg)) return {
1604
+ ok: false,
1605
+ expected: "{ type: 'portrait' | 'landscape' }",
1606
+ received: describeArgs(args)
1607
+ };
1608
+ const type = arg.type;
1609
+ if (type !== "portrait" && type !== "landscape") return {
1610
+ ok: false,
1611
+ expected: "{ type: 'portrait' | 'landscape' }",
1612
+ received: describeArgs(args)
1613
+ };
1614
+ return { ok: true };
1615
+ },
1616
+ example: "call_sdk('setDeviceOrientation', [{ type: 'landscape' }])"
1617
+ },
1618
+ {
1619
+ name: "setIosSwipeGestureEnabled",
1620
+ validateArgs(args) {
1621
+ const arg = args[0];
1622
+ if (!isObject$1(arg) || typeof arg.isEnabled !== "boolean") return {
1623
+ ok: false,
1624
+ expected: "{ isEnabled: boolean }",
1625
+ received: describeArgs(args)
1626
+ };
1627
+ return { ok: true };
1628
+ },
1629
+ example: "call_sdk('setIosSwipeGestureEnabled', [{ isEnabled: false }])"
1630
+ },
1631
+ {
1632
+ name: "setSecureScreen",
1633
+ validateArgs(args) {
1634
+ const arg = args[0];
1635
+ if (!isObject$1(arg) || typeof arg.enabled !== "boolean") return {
1636
+ ok: false,
1637
+ expected: "{ enabled: boolean }",
1638
+ received: describeArgs(args)
1639
+ };
1640
+ return { ok: true };
1641
+ },
1642
+ example: "call_sdk('setSecureScreen', [{ enabled: true }])"
1643
+ },
1644
+ {
1645
+ name: "setScreenAwakeMode",
1646
+ validateArgs(args) {
1647
+ const arg = args[0];
1648
+ if (!isObject$1(arg) || typeof arg.enabled !== "boolean") return {
1649
+ ok: false,
1650
+ expected: "{ enabled: boolean }",
1651
+ received: describeArgs(args)
1652
+ };
1653
+ return { ok: true };
1654
+ },
1655
+ example: "call_sdk('setScreenAwakeMode', [{ enabled: true }])"
1656
+ },
1657
+ {
1658
+ name: "getOperationalEnvironment",
1659
+ validateArgs(_args) {
1660
+ return { ok: true };
1661
+ },
1662
+ example: "call_sdk('getOperationalEnvironment', [])"
1663
+ },
1664
+ {
1665
+ name: "getPlatformOS",
1666
+ validateArgs(_args) {
1667
+ return { ok: true };
1668
+ },
1669
+ example: "call_sdk('getPlatformOS', [])"
1670
+ },
1671
+ {
1672
+ name: "getDeviceId",
1673
+ validateArgs(_args) {
1674
+ return { ok: true };
1675
+ },
1676
+ example: "call_sdk('getDeviceId', [])"
1677
+ },
1678
+ {
1679
+ name: "getLocale",
1680
+ validateArgs(_args) {
1681
+ return { ok: true };
1682
+ },
1683
+ example: "call_sdk('getLocale', [])"
1684
+ },
1685
+ {
1686
+ name: "getNetworkStatus",
1687
+ validateArgs(_args) {
1688
+ return { ok: true };
1689
+ },
1690
+ example: "call_sdk('getNetworkStatus', [])"
1691
+ },
1692
+ {
1693
+ name: "getSchemeUri",
1694
+ validateArgs(_args) {
1695
+ return { ok: true };
1696
+ },
1697
+ example: "call_sdk('getSchemeUri', [])"
1698
+ },
1699
+ {
1700
+ name: "requestReview",
1701
+ validateArgs(_args) {
1702
+ return { ok: true };
1703
+ },
1704
+ example: "call_sdk('requestReview', [])"
1705
+ },
1706
+ {
1707
+ name: "closeView",
1708
+ validateArgs(_args) {
1709
+ return { ok: true };
1710
+ },
1711
+ example: "call_sdk('closeView', [])"
1712
+ }
1713
+ ];
1714
+ const SIGNATURE_MAP = new Map(SIGNATURES.map((s) => [s.name, s]));
1715
+ /** 세션 내 passthrough 경고를 한 번만 emit하기 위한 Set */
1716
+ const _warnedPassthrough = /* @__PURE__ */ new Set();
1717
+ /**
1718
+ * 메서드 이름으로 시그니처를 조회한다.
1719
+ * 등록된 메서드이면 `SdkSignature`를 반환하고, 미등록이면 `undefined`.
1720
+ */
1721
+ function lookupSignature(name) {
1722
+ return SIGNATURE_MAP.get(name);
1723
+ }
1724
+ /**
1725
+ * 미등록 메서드에 대해 stderr에 passthrough 경고를 1회 출력한다.
1726
+ * 세션 내 동일 메서드 이름은 최초 1회만 출력.
1727
+ */
1728
+ function warnPassthrough(name) {
1729
+ if (_warnedPassthrough.has(name)) return;
1730
+ _warnedPassthrough.add(name);
1731
+ process.stderr.write(`[ait-debug] call_sdk: "${name}" 시그니처가 등록되지 않음 — passthrough\n`);
1732
+ }
1733
+ SIGNATURES.map((s) => s.name);
1734
+ //#endregion
931
1735
  //#region src/mcp/tools.ts
932
1736
  /** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
933
1737
  const DEBUG_TOOL_DEFINITIONS = [
@@ -938,7 +1742,8 @@ const DEBUG_TOOL_DEFINITIONS = [
938
1742
  type: "object",
939
1743
  properties: {},
940
1744
  required: []
941
- }
1745
+ },
1746
+ availableIn: "both"
942
1747
  },
943
1748
  {
944
1749
  name: "list_network_requests",
@@ -947,16 +1752,18 @@ const DEBUG_TOOL_DEFINITIONS = [
947
1752
  type: "object",
948
1753
  properties: {},
949
1754
  required: []
950
- }
1755
+ },
1756
+ availableIn: "both"
951
1757
  },
952
1758
  {
953
1759
  name: "list_pages",
954
- description: "Lists the mini-app page(s) the Chii relay currently sees attached, plus whether the cloudflared tunnel is up and the public wss relay URL the phone uses to attach. Call this first to confirm a page is attached before reading console/network.",
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.",
955
1761
  inputSchema: {
956
1762
  type: "object",
957
1763
  properties: {},
958
1764
  required: []
959
- }
1765
+ },
1766
+ availableIn: "both"
960
1767
  },
961
1768
  {
962
1769
  name: "build_attach_url",
@@ -978,7 +1785,8 @@ const DEBUG_TOOL_DEFINITIONS = [
978
1785
  }
979
1786
  },
980
1787
  required: ["scheme_url"]
981
- }
1788
+ },
1789
+ availableIn: "relay"
982
1790
  },
983
1791
  {
984
1792
  name: "get_dom_document",
@@ -987,7 +1795,8 @@ const DEBUG_TOOL_DEFINITIONS = [
987
1795
  type: "object",
988
1796
  properties: {},
989
1797
  required: []
990
- }
1798
+ },
1799
+ availableIn: "both"
991
1800
  },
992
1801
  {
993
1802
  name: "take_snapshot",
@@ -996,7 +1805,8 @@ const DEBUG_TOOL_DEFINITIONS = [
996
1805
  type: "object",
997
1806
  properties: {},
998
1807
  required: []
999
- }
1808
+ },
1809
+ availableIn: "both"
1000
1810
  },
1001
1811
  {
1002
1812
  name: "take_screenshot",
@@ -1005,16 +1815,18 @@ const DEBUG_TOOL_DEFINITIONS = [
1005
1815
  type: "object",
1006
1816
  properties: {},
1007
1817
  required: []
1008
- }
1818
+ },
1819
+ availableIn: "both"
1009
1820
  },
1010
1821
  {
1011
1822
  name: "measure_safe_area",
1012
- description: "Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. Read-only — does not modify page state. Use in a relay session (phone attached) to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires the relay to be attached — call list_pages first.",
1823
+ description: "Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. Read-only — does not modify page state. Tier C per RFC #277: the same Runtime.evaluate probe runs in both `mock` (devtools panel page with window.__ait state) and `relay` (real-device WebView with window.__sdk). The result includes a `source: \"mock\" | \"relay\"` field so consumers can identify provenance without inspecting payload values. Use in a relay session (phone attached) to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires a page to be attached — call list_pages first.",
1013
1824
  inputSchema: {
1014
1825
  type: "object",
1015
1826
  properties: {},
1016
1827
  required: []
1017
- }
1828
+ },
1829
+ availableIn: "both"
1018
1830
  },
1019
1831
  {
1020
1832
  name: "evaluate",
@@ -1026,11 +1838,25 @@ const DEBUG_TOOL_DEFINITIONS = [
1026
1838
  description: "JavaScript expression to evaluate in the page context."
1027
1839
  } },
1028
1840
  required: ["expression"]
1029
- }
1841
+ },
1842
+ availableIn: "both"
1843
+ },
1844
+ {
1845
+ name: "list_exceptions",
1846
+ description: "Lists JS-level exceptions captured via `Runtime.exceptionThrown` from the relay attached page. Includes timestamp, exception text, source URL/line, and stack trace. Use to root-cause SDK throws that may precede a Toss app crash (#265 / #267). The buffer holds up to 50 most recent exceptions and survives target replaced/crashed/destroyed events so an exception just before a crash is preserved. Returns up to 50 most recent by default.",
1847
+ inputSchema: {
1848
+ type: "object",
1849
+ properties: { limit: {
1850
+ type: "number",
1851
+ description: "Maximum number of exceptions to return (default 50, max 50)."
1852
+ } },
1853
+ required: []
1854
+ },
1855
+ availableIn: "both"
1030
1856
  },
1031
1857
  {
1032
1858
  name: "call_sdk",
1033
- description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 2/3 (real device relay) this hits the real SDK; on env 1 (local mock) it hits the mock SDK. Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle).",
1859
+ description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 2/3 (real device relay) this hits the real SDK; on env 1 (local mock) it hits the mock SDK. Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle).\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
1034
1860
  inputSchema: {
1035
1861
  type: "object",
1036
1862
  properties: {
@@ -1045,7 +1871,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1045
1871
  }
1046
1872
  },
1047
1873
  required: ["name"]
1048
- }
1874
+ },
1875
+ availableIn: "both"
1049
1876
  },
1050
1877
  {
1051
1878
  name: "AIT.getSdkCallHistory",
@@ -1054,7 +1881,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1054
1881
  type: "object",
1055
1882
  properties: {},
1056
1883
  required: []
1057
- }
1884
+ },
1885
+ availableIn: "both"
1058
1886
  },
1059
1887
  {
1060
1888
  name: "AIT.getMockState",
@@ -1063,7 +1891,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1063
1891
  type: "object",
1064
1892
  properties: {},
1065
1893
  required: []
1066
- }
1894
+ },
1895
+ availableIn: "both"
1067
1896
  },
1068
1897
  {
1069
1898
  name: "AIT.getOperationalEnvironment",
@@ -1072,7 +1901,8 @@ const DEBUG_TOOL_DEFINITIONS = [
1072
1901
  type: "object",
1073
1902
  properties: {},
1074
1903
  required: []
1075
- }
1904
+ },
1905
+ availableIn: "both"
1076
1906
  }
1077
1907
  ];
1078
1908
  const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
@@ -1080,6 +1910,34 @@ function isDebugToolName(name) {
1080
1910
  return DEBUG_TOOL_NAMES.has(name);
1081
1911
  }
1082
1912
  /**
1913
+ * Returns the `ToolAvailability` declared on a registered debug tool, or
1914
+ * `undefined` when the name is not a known debug tool. Used by the tool
1915
+ * registry to filter `tools/list` by current env and by the call handler to
1916
+ * reject env-mismatch invocations.
1917
+ */
1918
+ function getToolAvailability(name) {
1919
+ for (const t of DEBUG_TOOL_DEFINITIONS) if (t.name === name) return t.availableIn;
1920
+ }
1921
+ /**
1922
+ * Returns true when the named tool is available in the given environment.
1923
+ * Unknown tools return `false` — callers should reject them as unknown rather
1924
+ * than as env-mismatched.
1925
+ */
1926
+ function isToolAvailableIn(name, env) {
1927
+ const availability = getToolAvailability(name);
1928
+ if (availability === void 0) return false;
1929
+ if (availability === "both") return true;
1930
+ return availability === env;
1931
+ }
1932
+ /**
1933
+ * Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`
1934
+ * matches the given env. Pure — preserves order; both Tier C ("both") and the
1935
+ * matching single-env tier pass through.
1936
+ */
1937
+ function filterToolsByEnvironment(tools, env) {
1938
+ return tools.filter((t) => t.availableIn === "both" || t.availableIn === env);
1939
+ }
1940
+ /**
1083
1941
  * Tool names that are available before any page attaches (bootstrap tier).
1084
1942
  *
1085
1943
  * `build_attach_url` — pure URL synthesis, no attach needed.
@@ -1133,10 +1991,58 @@ function listNetworkRequests(connection) {
1133
1991
  };
1134
1992
  });
1135
1993
  }
1994
+ /** Formats a single CDP call frame into `at fn (url:line:col)`. */
1995
+ function formatCallFrame(frame) {
1996
+ return `at ${frame.functionName || "(anonymous)"} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`;
1997
+ }
1998
+ /** Normalizes a raw `Runtime.exceptionThrown` event into a `BufferedException`. */
1999
+ function normalizeException(event) {
2000
+ const { timestamp, exceptionDetails } = event;
2001
+ const frames = exceptionDetails.stackTrace?.callFrames;
2002
+ const stack = frames && frames.length > 0 ? frames.map(formatCallFrame).join("\n") : void 0;
2003
+ const exceptionText = exceptionDetails.exception?.description ?? void 0;
2004
+ const result = {
2005
+ timestamp,
2006
+ text: exceptionDetails.text,
2007
+ raw: event
2008
+ };
2009
+ if (exceptionDetails.url !== void 0) result.url = exceptionDetails.url;
2010
+ if (exceptionDetails.lineNumber !== void 0) result.lineNumber = exceptionDetails.lineNumber;
2011
+ if (exceptionDetails.columnNumber !== void 0) result.columnNumber = exceptionDetails.columnNumber;
2012
+ if (exceptionText !== void 0) result.exceptionText = exceptionText;
2013
+ if (stack !== void 0) result.stack = stack;
2014
+ return result;
2015
+ }
2016
+ /**
2017
+ * Returns the most recent buffered `Runtime.exceptionThrown` events, normalized.
2018
+ * Oldest-first; limited to `limit` entries (default 50, max 50).
2019
+ */
2020
+ function listExceptions(connection, limit = 50) {
2021
+ const cap = Math.min(Math.max(1, limit), 50);
2022
+ const events = connection.getBufferedEvents("Runtime.exceptionThrown");
2023
+ return (events.length > cap ? events.slice(events.length - cap) : events).map((e) => normalizeException(e));
2024
+ }
2025
+ function isCrashAware(conn) {
2026
+ return typeof conn.getLastCrashDetectedAt === "function" && typeof conn.getTargetLastSeenAt === "function";
2027
+ }
1136
2028
  function listPages(connection, tunnel) {
2029
+ const pages = connection.listTargets().map((t) => {
2030
+ const lastSeenMs = isCrashAware(connection) ? connection.getTargetLastSeenAt(t.id) : null;
2031
+ return {
2032
+ id: t.id,
2033
+ title: t.title,
2034
+ url: t.url,
2035
+ lastSeenAt: lastSeenMs !== null ? new Date(lastSeenMs).toISOString() : null
2036
+ };
2037
+ });
2038
+ const crashMs = isCrashAware(connection) ? connection.getLastCrashDetectedAt() : null;
2039
+ const crashDetectedAt = crashMs !== null ? new Date(crashMs).toISOString() : null;
1137
2040
  return {
1138
- pages: connection.listTargets(),
1139
- tunnel
2041
+ pages,
2042
+ tunnel,
2043
+ crashDetectedAt,
2044
+ crashWarning: crashDetectedAt ? `[ait-debug] page crash 감지됨 — 새 attach 필요 (관측 시각: ${crashDetectedAt})` : null,
2045
+ singleAttachModel: true
1140
2046
  };
1141
2047
  }
1142
2048
  /**
@@ -1332,11 +2238,13 @@ async function takeScreenshot(connection) {
1332
2238
  * The JS probe injected via `Runtime.evaluate`. It reads:
1333
2239
  * 1. `env(safe-area-inset-*)` via a temporary element with padding set to
1334
2240
  * those CSS env vars, then `getComputedStyle`.
1335
- * 2. `window.__sdk.SafeAreaInsets.get()` (1st priority) or
1336
- * `window.__sdk.getSafeAreaInsets()` (2nd priority) both surfaces
1337
- * confirmed live on iPhone 15 Pro relay. `window.__sdk` is only present
1338
- * in dogfood (__DEBUG_BUILD__) bundles; outside those it is undefined.
1339
- * If both paths fail the result carries `sdkInsetsError` explaining why.
2241
+ * 2. SDK insets via a priority chain so the SAME probe works on both relay
2242
+ * (real device) and mock (devtools panel page):
2243
+ * a. `window.__sdk.SafeAreaInsets.get()` dogfood bundle on real device.
2244
+ * b. `window.__sdk.getSafeAreaInsets()` — dogfood bundle (deprecated).
2245
+ * c. `window.__ait.state.safeAreaInsets` — devtools mock state (mock env).
2246
+ * The probe records `sdkInsetsSource` = `'window.__sdk'` | `'window.__ait'`
2247
+ * | `null`. If all paths fail the result carries `sdkInsetsError`.
1340
2248
  * 3. nav bar geometry: the SDK does not expose navBar height as a standalone
1341
2249
  * API — `.ait-navbar` DOM height is read as a cross-check, and
1342
2250
  * `navBarHeightSource` records where it came from.
@@ -1344,9 +2252,15 @@ async function takeScreenshot(connection) {
1344
2252
  *
1345
2253
  * Returns a plain JSON-serialisable object so `returnByValue: true` works.
1346
2254
  *
1347
- * NOTE: This expression is evaluated in the page context on the real device.
1348
- * It does not mutate any page state — the temporary element is removed after
1349
- * reading. No secret or auth token is read or returned.
2255
+ * NOTE: This expression is evaluated in the page context on the real device
2256
+ * (relay) or on the mock panel page. It does not mutate any page state — the
2257
+ * temporary element is removed after reading. No secret or auth token is read
2258
+ * or returned.
2259
+ *
2260
+ * RFC #277 Tier C parity: the SAME probe string runs in both envs. Mock fidelity
2261
+ * comes from the panel's `applyViewport` / `computeSafeAreaInsets` correctly
2262
+ * setting `window.__ait.state.safeAreaInsets` (#275). When that is correct,
2263
+ * the cssEnv + sdkInsets pair returned here matches the relay's shape.
1350
2264
  */
1351
2265
  const SAFE_AREA_PROBE_EXPRESSION = `
1352
2266
  (function() {
@@ -1366,17 +2280,28 @@ const SAFE_AREA_PROBE_EXPRESSION = `
1366
2280
  };
1367
2281
  document.documentElement.removeChild(el);
1368
2282
  var sdkInsets = null;
2283
+ var sdkInsetsSource = null;
1369
2284
  var sdkInsetsError = undefined;
1370
2285
  try {
1371
2286
  var sdk = window.__sdk;
2287
+ var ait = window.__ait;
1372
2288
  if (sdk && sdk.SafeAreaInsets && typeof sdk.SafeAreaInsets.get === 'function') {
1373
2289
  sdkInsets = sdk.SafeAreaInsets.get();
2290
+ sdkInsetsSource = 'window.__sdk';
1374
2291
  } else if (sdk && typeof sdk.getSafeAreaInsets === 'function') {
1375
2292
  sdkInsets = sdk.getSafeAreaInsets();
1376
- } else if (!sdk) {
1377
- sdkInsetsError = 'window.__sdk not available (non-dogfood bundle)';
1378
- } else {
2293
+ sdkInsetsSource = 'window.__sdk';
2294
+ } else if (ait && ait.state && ait.state.safeAreaInsets &&
2295
+ typeof ait.state.safeAreaInsets.top === 'number') {
2296
+ var s = ait.state.safeAreaInsets;
2297
+ sdkInsets = { top: s.top, bottom: s.bottom, left: s.left, right: s.right };
2298
+ sdkInsetsSource = 'window.__ait';
2299
+ } else if (!sdk && !ait) {
2300
+ sdkInsetsError = 'neither window.__sdk (relay) nor window.__ait (mock) available';
2301
+ } else if (sdk) {
1379
2302
  sdkInsetsError = 'neither SafeAreaInsets.get nor getSafeAreaInsets found on window.__sdk';
2303
+ } else {
2304
+ sdkInsetsError = 'window.__ait.state.safeAreaInsets is missing or malformed';
1380
2305
  }
1381
2306
  } catch(e) {
1382
2307
  sdkInsetsError = String(e && e.message || e);
@@ -1393,6 +2318,7 @@ const SAFE_AREA_PROBE_EXPRESSION = `
1393
2318
  var result = {
1394
2319
  cssEnv: cssEnv,
1395
2320
  sdkInsets: sdkInsets,
2321
+ sdkInsetsSource: sdkInsetsSource,
1396
2322
  navBarHeight: navBarHeight,
1397
2323
  navBarHeightSource: navBarHeightSource,
1398
2324
  innerWidth: window.innerWidth,
@@ -1409,9 +2335,11 @@ const SAFE_AREA_PROBE_EXPRESSION = `
1409
2335
  * The probe returns a JSON string (because `returnByValue:true` with a plain
1410
2336
  * object works unreliably across Chii relay versions — stringifying is safer).
1411
2337
  *
2338
+ * `source` is supplied by the caller (`measureSafeArea`) from the env SSoT.
2339
+ *
1412
2340
  * Throws if the result is missing, contains an exception, or cannot be parsed.
1413
2341
  */
1414
- function normalizeSafeAreaResult(rawValue) {
2342
+ function normalizeSafeAreaResult(rawValue, source) {
1415
2343
  if (typeof rawValue !== "string") throw new Error(`measure_safe_area: probe returned unexpected type "${typeof rawValue}" — expected JSON string`);
1416
2344
  let parsed;
1417
2345
  try {
@@ -1440,6 +2368,7 @@ function normalizeSafeAreaResult(rawValue) {
1440
2368
  left: 0
1441
2369
  };
1442
2370
  const sdkInsets = requireInsets("sdkInsets");
2371
+ const sdkInsetsSource = obj.sdkInsetsSource === "window.__sdk" || obj.sdkInsetsSource === "window.__ait" ? obj.sdkInsetsSource : null;
1443
2372
  const sdkInsetsError = typeof obj.sdkInsetsError === "string" ? obj.sdkInsetsError : void 0;
1444
2373
  const navBarHeight = typeof obj.navBarHeight === "number" ? obj.navBarHeight : null;
1445
2374
  const navBarHeightSource = typeof obj.navBarHeightSource === "string" ? obj.navBarHeightSource : "not-exposed-by-sdk";
@@ -1448,8 +2377,10 @@ function normalizeSafeAreaResult(rawValue) {
1448
2377
  const devicePixelRatio = typeof obj.devicePixelRatio === "number" ? obj.devicePixelRatio : 1;
1449
2378
  const userAgent = typeof obj.userAgent === "string" ? obj.userAgent : "";
1450
2379
  return {
2380
+ source,
1451
2381
  cssEnv,
1452
2382
  sdkInsets,
2383
+ sdkInsetsSource,
1453
2384
  ...sdkInsetsError !== void 0 ? { sdkInsetsError } : {},
1454
2385
  navBarHeight,
1455
2386
  navBarHeightSource,
@@ -1463,9 +2394,16 @@ function normalizeSafeAreaResult(rawValue) {
1463
2394
  * Runs the safe-area probe on the attached page and returns a normalized
1464
2395
  * `SafeAreaMeasurement`. Read-only — does not mutate page state.
1465
2396
  *
2397
+ * `source` is supplied by the caller from the env detection SSoT (see
2398
+ * `src/mcp/environment.ts`). The same `Runtime.evaluate` call runs in both
2399
+ * envs — the probe expression tries `window.__sdk` first (relay) then
2400
+ * `window.__ait` (mock), so mock fidelity is enforced by the panel's
2401
+ * `applyViewport`/`computeSafeAreaInsets` keeping `__ait.state.safeAreaInsets`
2402
+ * correct (RFC #277 Tier C parity, #275 model).
2403
+ *
1466
2404
  * Throws on CDP error, probe exception, or result parse failure.
1467
2405
  */
1468
- async function measureSafeArea(connection) {
2406
+ async function measureSafeArea(connection, source) {
1469
2407
  const result = await connection.send("Runtime.evaluate", {
1470
2408
  expression: SAFE_AREA_PROBE_EXPRESSION,
1471
2409
  returnByValue: true,
@@ -1475,7 +2413,7 @@ async function measureSafeArea(connection) {
1475
2413
  const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
1476
2414
  throw new Error(`measure_safe_area: probe threw — ${msg}`);
1477
2415
  }
1478
- return normalizeSafeAreaResult(result.result.value);
2416
+ return normalizeSafeAreaResult(result.result.value, source);
1479
2417
  }
1480
2418
  /**
1481
2419
  * Evaluates an arbitrary JS expression on the attached page via
@@ -1542,29 +2480,73 @@ function normalizeCallSdkResult(rawValue) {
1542
2480
  throw new Error("call_sdk: bridge result missing \"ok\" field");
1543
2481
  }
1544
2482
  /**
2483
+ * Looks up the most recent exception from the buffer that falls within the
2484
+ * triage window [windowStart, windowEnd]. Returns `undefined` if none found.
2485
+ *
2486
+ * The heuristic window is:
2487
+ * - windowStart = callStart - 50ms (catch sync throws before bridge fires)
2488
+ * - windowEnd = callEnd + 200ms (catch async throws resolved soon after)
2489
+ *
2490
+ * Only the most recent exception within the window is returned (the one most
2491
+ * likely to be causally related to the SDK call).
2492
+ */
2493
+ function findRecentException(connection, windowStart, windowEnd) {
2494
+ const events = connection.getBufferedEvents("Runtime.exceptionThrown");
2495
+ for (let i = events.length - 1; i >= 0; i--) {
2496
+ const e = events[i];
2497
+ if (e.timestamp >= windowStart && e.timestamp <= windowEnd) return normalizeException(e);
2498
+ }
2499
+ }
2500
+ /**
1545
2501
  * Calls a dogfood SDK method via `window.__sdkCall` on the attached page.
1546
2502
  * NOT read-only — SDK calls may have side effects.
1547
2503
  *
1548
2504
  * On env 2/3 (real device relay) this hits the real SDK; on env 1 (local
1549
2505
  * mock) it hits the mock SDK.
1550
2506
  *
2507
+ * 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면
2508
+ * `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).
2509
+ * 미등록 메서드는 passthrough + stderr 경고 1회.
2510
+ *
1551
2511
  * Throws on CDP error or result parse failure. Returns `{ok:false, error}`
1552
- * for bridge-level errors (method not found, SDK threw, bridge absent).
2512
+ * for bridge-level errors (method not found, SDK threw, bridge absent) or
2513
+ * argument schema violations.
2514
+ *
2515
+ * If a `Runtime.exceptionThrown` event was observed within the triage window
2516
+ * [callStart-50ms, callEnd+200ms], the result includes `recentException` for
2517
+ * crash triage. This window is a heuristic — it catches the common case of an
2518
+ * SDK throw immediately before/after the bridge resolves.
1553
2519
  *
1554
2520
  * SECRET-HANDLING: name, args, and the result value are NOT written to any log.
1555
2521
  */
1556
2522
  async function callSdk(connection, name, args) {
2523
+ const signature = lookupSignature(name);
2524
+ if (signature !== void 0) {
2525
+ const validation = signature.validateArgs(args);
2526
+ if (!validation.ok) return {
2527
+ ok: false,
2528
+ error: `call_sdk("${name}") 인자 시그니처 오류.\n받음: ${validation.received}\n기대: ${validation.expected}\n올바른 예시: ${signature.example}`
2529
+ };
2530
+ } else warnPassthrough(name);
2531
+ const callStart = Date.now();
1557
2532
  const expression = buildCallSdkExpression(name, args);
1558
2533
  const result = await connection.send("Runtime.evaluate", {
1559
2534
  expression,
1560
2535
  returnByValue: true,
1561
2536
  awaitPromise: true
1562
2537
  });
2538
+ const callEnd = Date.now();
1563
2539
  if (result.exceptionDetails) {
1564
2540
  const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
1565
2541
  throw new Error(`call_sdk threw: ${msg}`);
1566
2542
  }
1567
- return normalizeCallSdkResult(result.result.value);
2543
+ const sdkResult = normalizeCallSdkResult(result.result.value);
2544
+ const recentException = findRecentException(connection, callStart - 50, callEnd + 200);
2545
+ if (recentException !== void 0) return {
2546
+ ...sdkResult,
2547
+ recentException
2548
+ };
2549
+ return sdkResult;
1568
2550
  }
1569
2551
  /** Set of tool names served by the AIT source rather than the CDP connection. */
1570
2552
  const AIT_TOOL_NAMES = new Set([
@@ -1846,6 +2828,65 @@ async function printAttachBanner(input) {
1846
2828
  * Node-only.
1847
2829
  */
1848
2830
  /**
2831
+ * Parses `_deploymentId` from the query string of a scheme URL.
2832
+ *
2833
+ * Returns `null` when the param is absent or empty — callers treat that as
2834
+ * "no deploymentId filter; match on presence only" and fall back to the
2835
+ * original `attachedPages.length > 0` condition.
2836
+ *
2837
+ * SECRET-HANDLING: deploymentId is a public identifier and may appear in
2838
+ * debug output. Never confuse it with TOTP secrets or relay tunnel URLs.
2839
+ */
2840
+ function extractDeploymentId(schemeUrl) {
2841
+ try {
2842
+ const qIndex = schemeUrl.indexOf("?");
2843
+ if (qIndex === -1) return null;
2844
+ const id = new URLSearchParams(schemeUrl.slice(qIndex + 1)).get("_deploymentId");
2845
+ return id && id.length > 0 ? id : null;
2846
+ } catch {
2847
+ return null;
2848
+ }
2849
+ }
2850
+ /**
2851
+ * Waits for the first target matching `filterFn` to attach, using the
2852
+ * event-driven `waitForFirstTarget()` on `ChiiCdpConnection` instances, or
2853
+ * falling back to a polling loop for generic `CdpConnection` fakes (tests).
2854
+ *
2855
+ * This eliminates the polling-only race that previously caused `wait_for_attach`
2856
+ * to resolve before the relay had observed the first inbound CDP message from
2857
+ * the phone.
2858
+ *
2859
+ * @param connection - The CDP connection (production or fake).
2860
+ * @param filterFn - Resolves when this predicate is satisfied.
2861
+ * @param timeoutMs - Maximum wait time in ms.
2862
+ * @param pollIntervalMs - Fallback poll interval for non-ChiiCdpConnection.
2863
+ */
2864
+ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs = 1e3) {
2865
+ if (connection instanceof ChiiCdpConnection) return connection.waitForFirstTarget(filterFn, timeoutMs, pollIntervalMs);
2866
+ return new Promise((resolve, reject) => {
2867
+ const deadline = Date.now() + timeoutMs;
2868
+ let settled = false;
2869
+ const poll = setInterval(() => {
2870
+ const targets = connection.listTargets();
2871
+ if (filterFn(targets)) {
2872
+ settled = true;
2873
+ clearInterval(poll);
2874
+ resolve(targets);
2875
+ } else if (Date.now() >= deadline) {
2876
+ settled = true;
2877
+ clearInterval(poll);
2878
+ reject(/* @__PURE__ */ new Error(`waitForAttachWithEvents: 타임아웃 (${timeoutMs}ms)`));
2879
+ }
2880
+ }, pollIntervalMs);
2881
+ const targets = connection.listTargets();
2882
+ if (!settled && filterFn(targets)) {
2883
+ settled = true;
2884
+ clearInterval(poll);
2885
+ resolve(targets);
2886
+ }
2887
+ });
2888
+ }
2889
+ /**
1849
2890
  * Builds the debug-mode MCP server around an injected CDP connection + AIT
1850
2891
  * source + tunnel status getter. Pure wiring — does not start a relay or
1851
2892
  * tunnel, which is what makes the tool surface unit-testable.
@@ -1858,13 +2899,18 @@ async function printAttachBanner(input) {
1858
2899
  * naturally via `enableDomains`). The tier only controls visibility.
1859
2900
  */
1860
2901
  function createDebugServer(deps) {
1861
- const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer } = deps;
2902
+ const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep } = deps;
2903
+ const resolveEnvironment = getEnvDep ?? (() => getEnvironment({ connection }));
2904
+ const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({ connection }));
1862
2905
  const server = new Server({
1863
2906
  name: "ait-debug",
1864
- version: "0.1.40"
2907
+ version: "0.1.43"
1865
2908
  }, { capabilities: { tools: { listChanged: true } } });
1866
2909
  server.setRequestHandler(ListToolsRequestSchema, () => {
1867
- return { tools: connection.listTargets().length > 0 ? DEBUG_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) : DEBUG_TOOL_DEFINITIONS.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
2910
+ const env = resolveEnvironment();
2911
+ const attached = connection.listTargets().length > 0;
2912
+ const envFiltered = filterToolsByEnvironment(DEBUG_TOOL_DEFINITIONS, env);
2913
+ return { tools: attached ? envFiltered.map((tool) => ({ ...tool })) : envFiltered.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
1868
2914
  });
1869
2915
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1870
2916
  const name = request.params.name;
@@ -1875,6 +2921,18 @@ function createDebugServer(deps) {
1875
2921
  }],
1876
2922
  isError: true
1877
2923
  };
2924
+ const env = resolveEnvironment();
2925
+ if (!isToolAvailableIn(name, env)) {
2926
+ const reason = `tool ${name} is available only in ${getToolAvailability(name)}. Current environment is ${env} (${resolveEnvironmentReason()}).`;
2927
+ process.stderr.write(`[ait-debug] tier-filter rejected ${name}: ${reason}\n`);
2928
+ return {
2929
+ content: [{
2930
+ type: "text",
2931
+ text: reason
2932
+ }],
2933
+ isError: true
2934
+ };
2935
+ }
1878
2936
  if (isAitToolName(name)) try {
1879
2937
  await connection.enableDomains();
1880
2938
  switch (name) {
@@ -1897,6 +2955,20 @@ function createDebugServer(deps) {
1897
2955
  };
1898
2956
  const waitForAttach = request.params.arguments?.wait_for_attach === true;
1899
2957
  const openInBrowser = request.params.arguments?.open_in_browser !== false;
2958
+ const deploymentId = extractDeploymentId(schemeUrl);
2959
+ if (!deploymentId) process.stderr.write("[ait-debug] build_attach_url: no _deploymentId in scheme_url; matching on presence only\n");
2960
+ /** Returns true when the page list satisfies the attach condition. */
2961
+ const isMatchingPage = (pages) => {
2962
+ if (pages.length === 0) return false;
2963
+ if (deploymentId === null) return true;
2964
+ return pages.some((p) => p.url.includes(deploymentId));
2965
+ };
2966
+ /** Builds a timeout error message with diagnostic context. */
2967
+ const buildTimeoutError = (baseText, timeoutSec, observed) => {
2968
+ const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
2969
+ const observedNote = observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : "";
2970
+ return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
2971
+ };
1900
2972
  try {
1901
2973
  const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
1902
2974
  const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
@@ -1909,22 +2981,19 @@ function createDebugServer(deps) {
1909
2981
  type: "text",
1910
2982
  text: shortText
1911
2983
  }] };
1912
- const POLL_INTERVAL_MS = 1e3;
1913
- const TIMEOUT_MS = waitForAttachTimeoutMs;
1914
- const deadline = Date.now() + TIMEOUT_MS;
1915
2984
  let attachedPages = [];
1916
- while (Date.now() < deadline) {
2985
+ try {
2986
+ attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
2987
+ } catch {
1917
2988
  attachedPages = connection.listTargets();
1918
- if (attachedPages.length > 0) break;
1919
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
2989
+ return {
2990
+ content: [{
2991
+ type: "text",
2992
+ text: buildTimeoutError(shortText, waitForAttachTimeoutMs / 1e3, attachedPages)
2993
+ }],
2994
+ isError: true
2995
+ };
1920
2996
  }
1921
- if (attachedPages.length === 0) return {
1922
- content: [{
1923
- type: "text",
1924
- text: `${shortText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
1925
- }],
1926
- isError: true
1927
- };
1928
2997
  const pagesResult = listPages(connection, getTunnelStatus());
1929
2998
  return { content: [{
1930
2999
  type: "text",
@@ -1942,22 +3011,19 @@ function createDebugServer(deps) {
1942
3011
  type: "text",
1943
3012
  text: baseText
1944
3013
  }] };
1945
- const POLL_INTERVAL_MS_FB = 1e3;
1946
- const TIMEOUT_MS_FB = waitForAttachTimeoutMs;
1947
- const deadline2 = Date.now() + TIMEOUT_MS_FB;
1948
3014
  let attachedPagesFb = [];
1949
- while (Date.now() < deadline2) {
3015
+ try {
3016
+ attachedPagesFb = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3017
+ } catch {
1950
3018
  attachedPagesFb = connection.listTargets();
1951
- if (attachedPagesFb.length > 0) break;
1952
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS_FB));
3019
+ return {
3020
+ content: [{
3021
+ type: "text",
3022
+ text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPagesFb)
3023
+ }],
3024
+ isError: true
3025
+ };
1953
3026
  }
1954
- if (attachedPagesFb.length === 0) return {
1955
- content: [{
1956
- type: "text",
1957
- text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS_FB / 1e3}s — call list_pages to retry.`
1958
- }],
1959
- isError: true
1960
- };
1961
3027
  const pagesResultFb = listPages(connection, getTunnelStatus());
1962
3028
  return { content: [{
1963
3029
  type: "text",
@@ -1973,22 +3039,19 @@ function createDebugServer(deps) {
1973
3039
  type: "text",
1974
3040
  text: baseText
1975
3041
  }] };
1976
- const POLL_INTERVAL_MS = 1e3;
1977
- const TIMEOUT_MS = waitForAttachTimeoutMs;
1978
- const deadline = Date.now() + TIMEOUT_MS;
1979
3042
  let attachedPages = [];
1980
- while (Date.now() < deadline) {
3043
+ try {
3044
+ attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3045
+ } catch {
1981
3046
  attachedPages = connection.listTargets();
1982
- if (attachedPages.length > 0) break;
1983
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
3047
+ return {
3048
+ content: [{
3049
+ type: "text",
3050
+ text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPages)
3051
+ }],
3052
+ isError: true
3053
+ };
1984
3054
  }
1985
- if (attachedPages.length === 0) return {
1986
- content: [{
1987
- type: "text",
1988
- text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
1989
- }],
1990
- isError: true
1991
- };
1992
3055
  const pagesResult = listPages(connection, getTunnelStatus());
1993
3056
  return { content: [{
1994
3057
  type: "text",
@@ -2002,7 +3065,12 @@ function createDebugServer(deps) {
2002
3065
  await connection.enableDomains();
2003
3066
  } catch (err) {
2004
3067
  const message = err instanceof Error ? err.message : String(err);
2005
- if (name === "list_pages") return jsonResult$1(listPages(connection, getTunnelStatus()));
3068
+ if (name === "list_pages") {
3069
+ if (connection instanceof ChiiCdpConnection) try {
3070
+ await connection.refreshTargets();
3071
+ } catch {}
3072
+ return jsonResult$1(listPages(connection, getTunnelStatus()));
3073
+ }
2006
3074
  return {
2007
3075
  content: [{
2008
3076
  type: "text",
@@ -2014,8 +3082,16 @@ function createDebugServer(deps) {
2014
3082
  try {
2015
3083
  switch (name) {
2016
3084
  case "list_console_messages": return jsonResult$1(listConsoleMessages(connection));
3085
+ case "list_exceptions": {
3086
+ const rawLimit = request.params.arguments?.limit;
3087
+ return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
3088
+ }
2017
3089
  case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
2018
- case "list_pages": return jsonResult$1(listPages(connection, getTunnelStatus()));
3090
+ case "list_pages":
3091
+ if (connection instanceof ChiiCdpConnection) try {
3092
+ await connection.refreshTargets();
3093
+ } catch {}
3094
+ return jsonResult$1(listPages(connection, getTunnelStatus()));
2019
3095
  case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
2020
3096
  case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
2021
3097
  case "take_screenshot": {
@@ -2026,7 +3102,7 @@ function createDebugServer(deps) {
2026
3102
  mimeType: shot.mimeType
2027
3103
  }] };
2028
3104
  }
2029
- case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection));
3105
+ case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection, resolveEnvironment()));
2030
3106
  case "evaluate": {
2031
3107
  const expression = request.params.arguments?.expression;
2032
3108
  if (typeof expression !== "string" || expression === "") return {
@@ -2073,11 +3149,22 @@ function unknownTool(name) {
2073
3149
  isError: true
2074
3150
  };
2075
3151
  }
3152
+ /**
3153
+ * Detects whether an error is a relay/websocket disconnect error.
3154
+ * These are distinguished from "no page attached yet" errors because they
3155
+ * require enableDomains() to be called again (re-establish the websocket),
3156
+ * not just waiting for a target to appear.
3157
+ */
3158
+ function isDisconnectError(err) {
3159
+ if (!(err instanceof Error)) return false;
3160
+ const msg = err.message;
3161
+ return msg.includes("relay에 연결되어 있지 않습니다") || msg.includes("relay WebSocket") || msg.includes("replaced-by-new-attach") || msg.includes("Chii relay connection closed");
3162
+ }
2076
3163
  function errorResult(err, name) {
2077
3164
  return {
2078
3165
  content: [{
2079
3166
  type: "text",
2080
- text: `${name} failed: ${err instanceof Error ? err.message : String(err)}\nCall list_pages to confirm a mini-app has attached over the relay.`
3167
+ text: `${name} failed: ${err instanceof Error ? err.message : String(err)}${isDisconnectError(err) ? "\n\nrelay 연결이 끊겼습니다. list_pages → enableDomains() 재호출로 재연결하세요. 폰이 백그라운드로 내려갔거나 미니앱이 종료됐을 수 있습니다." : "\nCall list_pages to confirm a mini-app has attached over the relay."}`
2081
3168
  }],
2082
3169
  isError: true
2083
3170
  };
@@ -2091,19 +3178,28 @@ function errorResult(err, name) {
2091
3178
  * `server.sendToolListChanged()` exactly once — on the first transition — then
2092
3179
  * clears itself. Shutdown calls `stop()` to clear the interval.
2093
3180
  *
3181
+ * `onFirstAttach` is called once on the 0→N transition (or immediately when
3182
+ * already attached). Use this to trigger side-effects such as auto-opening
3183
+ * Chrome DevTools (issue #282). The callback is optional; omitting it preserves
3184
+ * the previous behaviour exactly.
3185
+ *
2094
3186
  * SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
2095
3187
  * Only an attach-detected stderr line is emitted (no target details).
2096
3188
  *
2097
3189
  * @returns `stop` — call this during shutdown to clear the interval.
2098
3190
  */
2099
- function startAttachWatcher(connection, server, intervalMs = 1e3) {
3191
+ function startAttachWatcher(connection, server, intervalMs = 1e3, onFirstAttach) {
2100
3192
  let wasAttached = connection.listTargets().length > 0;
2101
- if (wasAttached) server.sendToolListChanged();
3193
+ if (wasAttached) {
3194
+ server.sendToolListChanged();
3195
+ onFirstAttach?.();
3196
+ }
2102
3197
  const handle = setInterval(() => {
2103
3198
  const isAttached = connection.listTargets().length > 0;
2104
3199
  if (!wasAttached && isAttached) {
2105
3200
  wasAttached = true;
2106
3201
  server.sendToolListChanged();
3202
+ onFirstAttach?.();
2107
3203
  clearInterval(handle);
2108
3204
  }
2109
3205
  }, intervalMs);
@@ -2143,6 +3239,7 @@ function buildRelayVerifyAuth() {
2143
3239
  * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
2144
3240
  */
2145
3241
  async function runDebugServer(options = {}) {
3242
+ const lockHandle = acquireLock();
2146
3243
  const relayPort = options.relayPort ?? 0;
2147
3244
  const verifyAuth = buildRelayVerifyAuth();
2148
3245
  const totpEnabled = verifyAuth !== void 0;
@@ -2162,6 +3259,7 @@ async function runDebugServer(options = {}) {
2162
3259
  up: true,
2163
3260
  wssUrl: t.wssUrl
2164
3261
  };
3262
+ lockHandle.updateWssUrl(t.wssUrl);
2165
3263
  return printAttachBanner({
2166
3264
  wssUrl: t.wssUrl,
2167
3265
  totpEnabled
@@ -2174,12 +3272,13 @@ async function runDebugServer(options = {}) {
2174
3272
  const connection = new ChiiCdpConnection({ relayBaseUrl: relay.baseUrl });
2175
3273
  const aitSource = new ChiiAitSource(connection);
2176
3274
  let qrServer;
2177
- startQrHttpServer().then((s) => {
2178
- qrServer = s;
2179
- }, (err) => {
3275
+ try {
3276
+ qrServer = await startQrHttpServer();
3277
+ } catch (err) {
2180
3278
  const message = err instanceof Error ? err.message : String(err);
2181
3279
  process.stderr.write(`[ait-debug] QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}\n`);
2182
- });
3280
+ }
3281
+ const devtoolsOpener = new AutoDevtoolsOpener();
2183
3282
  const server = createDebugServer({
2184
3283
  connection,
2185
3284
  aitSource,
@@ -2200,6 +3299,7 @@ async function runDebugServer(options = {}) {
2200
3299
  relay.close();
2201
3300
  server.close();
2202
3301
  qrServer?.close();
3302
+ lockHandle.release();
2203
3303
  };
2204
3304
  process.once("SIGINT", shutdown);
2205
3305
  process.once("SIGTERM", shutdown);
@@ -2209,6 +3309,7 @@ async function runDebugServer(options = {}) {
2209
3309
  closed = true;
2210
3310
  attachWatcher?.stop();
2211
3311
  tunnel?.stop();
3312
+ lockHandle.release();
2212
3313
  }
2213
3314
  });
2214
3315
  process.on("uncaughtException", (err) => {
@@ -2222,7 +3323,9 @@ async function runDebugServer(options = {}) {
2222
3323
  process.exit(1);
2223
3324
  });
2224
3325
  await server.connect(transport);
2225
- attachWatcher = startAttachWatcher(connection, server);
3326
+ attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
3327
+ devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({ connection }));
3328
+ });
2226
3329
  }
2227
3330
  /**
2228
3331
  * Boots the local-browser debug stack and serves it over stdio:
@@ -2243,6 +3346,7 @@ async function runDebugServer(options = {}) {
2243
3346
  * expected and noted in the PR as an explicit out-of-scope follow-up.
2244
3347
  */
2245
3348
  async function runLocalDebugServer(options = {}) {
3349
+ const lockHandle = acquireLock();
2246
3350
  const chromium = await launchChromium({
2247
3351
  port: options.cdpPort ?? 0,
2248
3352
  devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
@@ -2269,6 +3373,7 @@ async function runLocalDebugServer(options = {}) {
2269
3373
  connection.close();
2270
3374
  chromium.stop();
2271
3375
  server.close();
3376
+ lockHandle.release();
2272
3377
  };
2273
3378
  process.once("SIGINT", shutdown);
2274
3379
  process.once("SIGTERM", shutdown);
@@ -2278,6 +3383,7 @@ async function runLocalDebugServer(options = {}) {
2278
3383
  closed = true;
2279
3384
  attachWatcher?.stop();
2280
3385
  chromium.stop();
3386
+ lockHandle.release();
2281
3387
  }
2282
3388
  });
2283
3389
  process.on("uncaughtException", (err) => {
@@ -2366,7 +3472,13 @@ var HttpAitSource = class {
2366
3472
  * }
2367
3473
  * }
2368
3474
  */
2369
- /** Tool descriptors served by the dev-mode server. */
3475
+ /**
3476
+ * Tool descriptors served by the dev-mode server.
3477
+ *
3478
+ * All dev-mode tools are Tier C (both envs) per RFC #277 — the dev-mode server
3479
+ * itself is the mock-side embodiment of those Tier C tools. `availableIn` is
3480
+ * declared so the surface stays consistent with the debug-mode registry.
3481
+ */
2370
3482
  const DEV_TOOL_DEFINITIONS = [
2371
3483
  {
2372
3484
  name: "AIT.getMockState",
@@ -2375,7 +3487,8 @@ const DEV_TOOL_DEFINITIONS = [
2375
3487
  type: "object",
2376
3488
  properties: {},
2377
3489
  required: []
2378
- }
3490
+ },
3491
+ availableIn: "both"
2379
3492
  },
2380
3493
  {
2381
3494
  name: "AIT.getOperationalEnvironment",
@@ -2384,7 +3497,8 @@ const DEV_TOOL_DEFINITIONS = [
2384
3497
  type: "object",
2385
3498
  properties: {},
2386
3499
  required: []
2387
- }
3500
+ },
3501
+ availableIn: "both"
2388
3502
  },
2389
3503
  {
2390
3504
  name: "AIT.getSdkCallHistory",
@@ -2393,7 +3507,8 @@ const DEV_TOOL_DEFINITIONS = [
2393
3507
  type: "object",
2394
3508
  properties: {},
2395
3509
  required: []
2396
- }
3510
+ },
3511
+ availableIn: "both"
2397
3512
  },
2398
3513
  {
2399
3514
  name: "devtools_get_mock_state",
@@ -2402,7 +3517,8 @@ const DEV_TOOL_DEFINITIONS = [
2402
3517
  type: "object",
2403
3518
  properties: {},
2404
3519
  required: []
2405
- }
3520
+ },
3521
+ availableIn: "both"
2406
3522
  }
2407
3523
  ];
2408
3524
  const DEV_TOOL_NAMES = new Set(DEV_TOOL_DEFINITIONS.map((t) => t.name));
@@ -2412,7 +3528,7 @@ function createDevServer(deps = {}) {
2412
3528
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
2413
3529
  const server = new Server({
2414
3530
  name: "ait-devtools",
2415
- version: "0.1.40"
3531
+ version: "0.1.43"
2416
3532
  }, { capabilities: { tools: {} } });
2417
3533
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
2418
3534
  server.setRequestHandler(CallToolRequestSchema, async (request) => {