@ait-co/devtools 0.1.40 → 0.1.41

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
@@ -15,23 +15,23 @@ import { platform } from "node:os";
15
15
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
16
16
  import { Tunnel, bin, install } from "cloudflared";
17
17
  //#region src/mcp/ait-chii-source.ts
18
- function isObject$3(value) {
18
+ function isObject$4(value) {
19
19
  return typeof value === "object" && value !== null;
20
20
  }
21
21
  /** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
22
22
  function asSdkCallHistory(raw) {
23
- if (isObject$3(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
23
+ if (isObject$4(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
24
24
  return { calls: [] };
25
25
  }
26
26
  /** Narrows an `AIT.getMockState` response to an opaque record. */
27
27
  function asMockState(raw) {
28
- return isObject$3(raw) ? raw : {};
28
+ return isObject$4(raw) ? raw : {};
29
29
  }
30
30
  /** Narrows an `AIT.getOperationalEnvironment` response. */
31
31
  function asOperationalEnvironment(raw) {
32
32
  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
33
+ environment: isObject$4(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
34
+ sdkVersion: isObject$4(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
35
35
  };
36
36
  }
37
37
  var ChiiAitSource = class {
@@ -65,7 +65,7 @@ var ChiiAitSource = class {
65
65
  */
66
66
  /** Max events retained per domain ring buffer. */
67
67
  const DEFAULT_BUFFER_SIZE$1 = 500;
68
- function isObject$2(value) {
68
+ function isObject$3(value) {
69
69
  return typeof value === "object" && value !== null;
70
70
  }
71
71
  function parseInbound$1(raw) {
@@ -75,13 +75,13 @@ function parseInbound$1(raw) {
75
75
  } catch {
76
76
  return null;
77
77
  }
78
- if (!isObject$2(parsed)) return null;
78
+ if (!isObject$3(parsed)) return null;
79
79
  const message = {};
80
80
  if (typeof parsed.id === "number") message.id = parsed.id;
81
81
  if (typeof parsed.method === "string") message.method = parsed.method;
82
82
  if ("params" in parsed) message.params = parsed.params;
83
83
  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 };
84
+ if (isObject$3(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
85
85
  return message;
86
86
  }
87
87
  const PHASE_1_EVENTS$1 = [
@@ -90,25 +90,67 @@ const PHASE_1_EVENTS$1 = [
90
90
  "Network.responseReceived"
91
91
  ];
92
92
  /**
93
+ * Ring buffer size for `Runtime.exceptionThrown`.
94
+ *
95
+ * Exceptions are rarer than console messages but each is heavier (stack
96
+ * trace). 50 is generous enough to cover a crash scenario while keeping
97
+ * memory bounded.
98
+ *
99
+ * **Lifecycle note**: the exception buffer intentionally survives `replaced` /
100
+ * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
101
+ * transitions. Rationale: an exception fired just before a crash is exactly
102
+ * the signal we want to preserve for root-cause analysis. The buffer
103
+ * represents "exceptions seen in this MCP session", not "exceptions in the
104
+ * current page".
105
+ */
106
+ const EXCEPTION_BUFFER_SIZE = 50;
107
+ /** Default per-command timeout if neither option nor env var is set. */
108
+ const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
109
+ /**
93
110
  * Production CDP connection. Polls the relay for the first attached target,
94
111
  * opens a client websocket to it, enables Phase 1 domains, and buffers events.
95
112
  */
96
113
  var ChiiCdpConnection = class {
97
114
  relayBaseUrl;
98
115
  bufferSize;
116
+ commandTimeoutMs;
99
117
  emitter = new EventEmitter();
100
118
  buffers = /* @__PURE__ */ new Map();
101
119
  targets = /* @__PURE__ */ new Map();
102
120
  ws = null;
121
+ connectionState = "idle";
103
122
  nextCommandId = 1;
123
+ /**
124
+ * The single active target id under the single-attach model.
125
+ * Updated by `refreshTargets()` whenever a non-null target is present.
126
+ * Used to detect a new (different) target attach and evict the previous one.
127
+ */
128
+ activeTargetId = null;
104
129
  /** In-flight enableDomains() promise — concurrent callers share it. */
105
130
  enablingPromise = null;
106
131
  /** Pending request→response commands keyed by CDP message id. */
107
132
  pending = /* @__PURE__ */ new Map();
133
+ /**
134
+ * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
135
+ * or `null` if no crash has been detected since the last `enableDomains()`.
136
+ */
137
+ lastCrashDetectedAt = null;
138
+ /**
139
+ * Per-target last-seen timestamp (ms since epoch). Updated on any inbound
140
+ * CDP message carrying data from a target. Keyed by target id.
141
+ */
142
+ targetLastSeenAt = /* @__PURE__ */ new Map();
143
+ /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
144
+ heartbeatHandle = null;
145
+ /** Lifecycle event listeners (crash / destroyed / detached). */
146
+ lifecycleListeners = [];
108
147
  constructor(options) {
109
148
  this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
110
149
  this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE$1;
150
+ const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
151
+ this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
111
152
  for (const event of PHASE_1_EVENTS$1) this.buffers.set(event, []);
153
+ this.buffers.set("Runtime.exceptionThrown", []);
112
154
  this.emitter.setMaxListeners(0);
113
155
  }
114
156
  /** Refresh the attached-target list from the relay's `GET /targets`. */
@@ -116,22 +158,57 @@ var ChiiCdpConnection = class {
116
158
  const res = await fetch(`${this.relayBaseUrl}/targets`);
117
159
  if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
118
160
  const body = await res.json();
119
- const list = isObject$2(body) && Array.isArray(body.targets) ? body.targets : [];
161
+ const list = isObject$3(body) && Array.isArray(body.targets) ? body.targets : [];
162
+ let newestTargetId = null;
163
+ for (const item of list) {
164
+ if (!isObject$3(item) || typeof item.id !== "string") continue;
165
+ newestTargetId = item.id;
166
+ }
167
+ if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
168
+ const prevId = this.activeTargetId;
169
+ process.stderr.write(`[ait-debug] 이전 page 세션 종료 — 새 attach로 교체 (prev=${prevId})\n`);
170
+ this.evictTarget(prevId);
171
+ }
120
172
  this.targets.clear();
121
173
  for (const item of list) {
122
- if (!isObject$2(item) || typeof item.id !== "string") continue;
174
+ if (!isObject$3(item) || typeof item.id !== "string") continue;
175
+ if (item.id !== newestTargetId) continue;
123
176
  this.targets.set(item.id, {
124
177
  id: item.id,
125
178
  title: typeof item.title === "string" ? item.title : "",
126
179
  url: typeof item.url === "string" ? item.url : ""
127
180
  });
128
181
  }
182
+ if (newestTargetId !== null) this.activeTargetId = newestTargetId;
183
+ else this.activeTargetId = null;
129
184
  return [...this.targets.values()];
130
185
  }
131
186
  listTargets() {
132
187
  return [...this.targets.values()];
133
188
  }
134
189
  /**
190
+ * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
191
+ * detected since the last `enableDomains()` call, or `null` if none.
192
+ */
193
+ getLastCrashDetectedAt() {
194
+ return this.lastCrashDetectedAt;
195
+ }
196
+ /**
197
+ * Last-seen timestamp (ms since epoch) for a given target id, or `null` if
198
+ * the target is unknown / no message has been received from it yet.
199
+ */
200
+ getTargetLastSeenAt(targetId) {
201
+ return this.targetLastSeenAt.get(targetId) ?? null;
202
+ }
203
+ /** Subscribe to target lifecycle events (crash / destroyed / detached). */
204
+ onLifecycle(listener) {
205
+ this.lifecycleListeners.push(listener);
206
+ return () => {
207
+ const idx = this.lifecycleListeners.indexOf(listener);
208
+ if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
209
+ };
210
+ }
211
+ /**
135
212
  * Connect a client websocket to the first attached target and enable Phase 1
136
213
  * domains. Resolves once the socket is open and enable commands are sent.
137
214
  */
@@ -152,11 +229,19 @@ var ChiiCdpConnection = class {
152
229
  ws.once("open", () => resolve());
153
230
  ws.once("error", (err) => reject(err));
154
231
  });
232
+ this.lastCrashDetectedAt = null;
233
+ this.targetLastSeenAt.clear();
234
+ this.connectionState = "connected";
155
235
  ws.on("message", (data) => this.handleMessage(data.toString()));
236
+ ws.on("close", () => this.handleDisconnect("relay WebSocket 연결이 끊겼습니다"));
237
+ ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
156
238
  this.sendFireAndForget("Runtime.enable");
157
239
  this.sendFireAndForget("Network.enable");
158
240
  this.sendFireAndForget("DOM.enable");
159
241
  this.sendFireAndForget("Page.enable");
242
+ this.sendFireAndForget("Inspector.enable");
243
+ this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
244
+ this.startHeartbeat(target.id);
160
245
  }
161
246
  /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
162
247
  sendFireAndForget(method, params = {}) {
@@ -179,15 +264,35 @@ var ChiiCdpConnection = class {
179
264
  * Issue an arbitrary request→response command over the relay and resolve with
180
265
  * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
181
266
  * `AIT.*` methods, forwarded over the same Chii channel) build on this.
267
+ *
268
+ * Rejects immediately if the connection is disconnected (fail-fast — no
269
+ * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
270
+ * reattach.
271
+ *
272
+ * Times out after `commandTimeoutMs` (default 30s, env
273
+ * `AIT_CDP_COMMAND_TIMEOUT_MS`). On timeout the pending entry is cleaned up
274
+ * and the promise rejects with a descriptive Korean error.
182
275
  */
183
276
  sendCommand(method, params = {}) {
277
+ if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
184
278
  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
279
  const id = this.nextCommandId++;
186
280
  const ws = this.ws;
281
+ const timeoutMs = this.commandTimeoutMs;
187
282
  return new Promise((resolve, reject) => {
283
+ const handle = setTimeout(() => {
284
+ this.pending.delete(id);
285
+ reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
286
+ }, timeoutMs);
188
287
  this.pending.set(id, {
189
- resolve,
190
- reject
288
+ resolve: (v) => {
289
+ clearTimeout(handle);
290
+ resolve(v);
291
+ },
292
+ reject: (e) => {
293
+ clearTimeout(handle);
294
+ reject(e);
295
+ }
191
296
  });
192
297
  ws.send(JSON.stringify({
193
298
  id,
@@ -196,6 +301,117 @@ var ChiiCdpConnection = class {
196
301
  }));
197
302
  });
198
303
  }
304
+ /**
305
+ * Called on WebSocket `close` or `error` after a successful connection.
306
+ * Rejects all pending commands and marks the connection as disconnected so
307
+ * subsequent `sendCommand` calls fail fast (no auto-reconnect).
308
+ */
309
+ handleDisconnect(reason) {
310
+ if (this.connectionState === "disconnected") return;
311
+ this.connectionState = "disconnected";
312
+ this.ws = null;
313
+ this.stopHeartbeat();
314
+ const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
315
+ for (const waiter of this.pending.values()) waiter.reject(err);
316
+ this.pending.clear();
317
+ }
318
+ /**
319
+ * Evict a previously active target under the single-attach model.
320
+ * Rejects pending commands with a 'replaced-by-new-attach' reason and emits
321
+ * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
322
+ * targetId. The caller is responsible for rebuilding the targets map afterwards.
323
+ *
324
+ * The error message uses 'replaced-by-new-attach' so test assertions can match it.
325
+ */
326
+ evictTarget(targetId) {
327
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
328
+ this.targets.delete(targetId);
329
+ this.targetLastSeenAt.delete(targetId);
330
+ const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
331
+ for (const waiter of this.pending.values()) waiter.reject(err);
332
+ this.pending.clear();
333
+ const event = {
334
+ kind: "replaced",
335
+ targetId,
336
+ detectedAt
337
+ };
338
+ for (const listener of this.lifecycleListeners) try {
339
+ listener(event);
340
+ } catch {}
341
+ }
342
+ /**
343
+ * Handle a page-level crash or target destruction event.
344
+ * Removes the target from the in-memory map, rejects all pending commands,
345
+ * and emits a lifecycle event.
346
+ *
347
+ * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
348
+ * @param targetId - The target ID from the event params (may be null for
349
+ * Inspector.targetCrashed which has no targetId in the params).
350
+ */
351
+ handleTargetGone(kind, targetId) {
352
+ const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
353
+ this.lastCrashDetectedAt = Date.now();
354
+ if (targetId !== null) {
355
+ this.targets.delete(targetId);
356
+ this.targetLastSeenAt.delete(targetId);
357
+ if (this.activeTargetId === targetId) this.activeTargetId = null;
358
+ } else {
359
+ this.targets.clear();
360
+ this.targetLastSeenAt.clear();
361
+ this.activeTargetId = null;
362
+ }
363
+ 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()로 재연결).`);
364
+ for (const waiter of this.pending.values()) waiter.reject(err);
365
+ this.pending.clear();
366
+ const event = {
367
+ kind,
368
+ targetId,
369
+ detectedAt
370
+ };
371
+ for (const listener of this.lifecycleListeners) try {
372
+ listener(event);
373
+ } catch {}
374
+ }
375
+ /**
376
+ * Start the optional CDP heartbeat loop.
377
+ *
378
+ * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
379
+ * we send `Runtime.evaluate({expression: '1'})` to each active target. If
380
+ * the command times out (2 s hard deadline) or errors, we treat the target
381
+ * as dead and call `handleTargetGone`.
382
+ *
383
+ * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
384
+ * even when the phone app has crashed, so the ws-level disconnect (#252) won't
385
+ * fire. The heartbeat catches this gap.
386
+ *
387
+ * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
388
+ */
389
+ startHeartbeat(initialTargetId) {
390
+ this.stopHeartbeat();
391
+ const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
392
+ if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
393
+ const PING_TIMEOUT_MS = 2e3;
394
+ this.heartbeatHandle = setInterval(() => {
395
+ const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
396
+ for (const targetId of targetIds) {
397
+ const pingPromise = this.sendCommand("Runtime.evaluate", {
398
+ expression: "1",
399
+ returnByValue: true,
400
+ timeout: PING_TIMEOUT_MS
401
+ });
402
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
403
+ Promise.race([pingPromise, timeoutPromise]).catch(() => {
404
+ if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
405
+ });
406
+ }
407
+ }, envMs);
408
+ }
409
+ stopHeartbeat() {
410
+ if (this.heartbeatHandle !== null) {
411
+ clearInterval(this.heartbeatHandle);
412
+ this.heartbeatHandle = null;
413
+ }
414
+ }
199
415
  handleMessage(raw) {
200
416
  const message = parseInbound$1(raw);
201
417
  if (!message) return;
@@ -206,13 +422,30 @@ var ChiiCdpConnection = class {
206
422
  else waiter.resolve(message.result);
207
423
  return;
208
424
  }
425
+ const now = Date.now();
426
+ for (const targetId of this.targets.keys()) this.targetLastSeenAt.set(targetId, now);
209
427
  if (typeof message.method !== "string") return;
428
+ if (message.method === "Inspector.targetCrashed") {
429
+ this.handleTargetGone("crashed", null);
430
+ return;
431
+ }
432
+ if (message.method === "Target.targetDestroyed") {
433
+ const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
434
+ this.handleTargetGone("destroyed", targetId);
435
+ return;
436
+ }
437
+ if (message.method === "Target.detachedFromTarget") {
438
+ const targetId = isObject$3(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
439
+ this.handleTargetGone("detached", targetId);
440
+ return;
441
+ }
210
442
  if (!this.buffers.has(message.method)) return;
211
443
  const event = message.method;
212
444
  const buffer = this.buffers.get(event);
213
445
  if (!buffer) return;
214
446
  buffer.push(message.params);
215
- if (buffer.length > this.bufferSize) buffer.shift();
447
+ const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
448
+ if (buffer.length > cap) buffer.shift();
216
449
  this.emitter.emit(event, message.params);
217
450
  }
218
451
  getBufferedEvents(event) {
@@ -224,10 +457,10 @@ var ChiiCdpConnection = class {
224
457
  }
225
458
  /** Close the relay client websocket and reject any in-flight commands. */
226
459
  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();
460
+ const ws = this.ws;
461
+ this.stopHeartbeat();
462
+ this.handleDisconnect("Chii relay connection closed");
463
+ ws?.close();
231
464
  }
232
465
  };
233
466
  //#endregion
@@ -335,7 +568,7 @@ async function startChiiRelay(options = {}) {
335
568
  */
336
569
  /** Max events retained per domain ring buffer. */
337
570
  const DEFAULT_BUFFER_SIZE = 500;
338
- function isObject$1(value) {
571
+ function isObject$2(value) {
339
572
  return typeof value === "object" && value !== null;
340
573
  }
341
574
  function parseInbound(raw) {
@@ -345,13 +578,13 @@ function parseInbound(raw) {
345
578
  } catch {
346
579
  return null;
347
580
  }
348
- if (!isObject$1(parsed)) return null;
581
+ if (!isObject$2(parsed)) return null;
349
582
  const message = {};
350
583
  if (typeof parsed.id === "number") message.id = parsed.id;
351
584
  if (typeof parsed.method === "string") message.method = parsed.method;
352
585
  if ("params" in parsed) message.params = parsed.params;
353
586
  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 };
587
+ if (isObject$2(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
355
588
  return message;
356
589
  }
357
590
  const PHASE_1_EVENTS = [
@@ -404,7 +637,7 @@ var LocalCdpConnection = class {
404
637
  this.targets.clear();
405
638
  let selected = null;
406
639
  for (const item of list) {
407
- if (!isObject$1(item) || typeof item.id !== "string") continue;
640
+ if (!isObject$2(item) || typeof item.id !== "string") continue;
408
641
  const cdpTarget = {
409
642
  id: item.id,
410
643
  title: typeof item.title === "string" ? item.title : "",
@@ -928,6 +1161,162 @@ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
928
1161
  return `${base}?${query}${hash}`;
929
1162
  }
930
1163
  //#endregion
1164
+ //#region src/mcp/sdk-signatures.ts
1165
+ function isObject$1(v) {
1166
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1167
+ }
1168
+ function describeArgs(args) {
1169
+ try {
1170
+ return JSON.stringify(args);
1171
+ } catch {
1172
+ return String(args);
1173
+ }
1174
+ }
1175
+ /**
1176
+ * 등록된 메서드 목록.
1177
+ *
1178
+ * 시그니처 출처 확인:
1179
+ * - 함수가 인자를 받지 않으면 args[0] 없음 → `args.length === 0`을 체크하지 않고
1180
+ * 그냥 통과시킨다(args 무시하는 stub가 많아서 noArgs 체크가 noise).
1181
+ * - 실 SDK 시그니처는 `src/__typecheck.ts`의 `Assert<Mock, Original>` 줄로 보장.
1182
+ */
1183
+ const SIGNATURES = [
1184
+ {
1185
+ name: "setDeviceOrientation",
1186
+ validateArgs(args) {
1187
+ const arg = args[0];
1188
+ if (!isObject$1(arg)) return {
1189
+ ok: false,
1190
+ expected: "{ type: 'portrait' | 'landscape' }",
1191
+ received: describeArgs(args)
1192
+ };
1193
+ const type = arg.type;
1194
+ if (type !== "portrait" && type !== "landscape") return {
1195
+ ok: false,
1196
+ expected: "{ type: 'portrait' | 'landscape' }",
1197
+ received: describeArgs(args)
1198
+ };
1199
+ return { ok: true };
1200
+ },
1201
+ example: "call_sdk('setDeviceOrientation', [{ type: 'landscape' }])"
1202
+ },
1203
+ {
1204
+ name: "setIosSwipeGestureEnabled",
1205
+ validateArgs(args) {
1206
+ const arg = args[0];
1207
+ if (!isObject$1(arg) || typeof arg.isEnabled !== "boolean") return {
1208
+ ok: false,
1209
+ expected: "{ isEnabled: boolean }",
1210
+ received: describeArgs(args)
1211
+ };
1212
+ return { ok: true };
1213
+ },
1214
+ example: "call_sdk('setIosSwipeGestureEnabled', [{ isEnabled: false }])"
1215
+ },
1216
+ {
1217
+ name: "setSecureScreen",
1218
+ validateArgs(args) {
1219
+ const arg = args[0];
1220
+ if (!isObject$1(arg) || typeof arg.enabled !== "boolean") return {
1221
+ ok: false,
1222
+ expected: "{ enabled: boolean }",
1223
+ received: describeArgs(args)
1224
+ };
1225
+ return { ok: true };
1226
+ },
1227
+ example: "call_sdk('setSecureScreen', [{ enabled: true }])"
1228
+ },
1229
+ {
1230
+ name: "setScreenAwakeMode",
1231
+ validateArgs(args) {
1232
+ const arg = args[0];
1233
+ if (!isObject$1(arg) || typeof arg.enabled !== "boolean") return {
1234
+ ok: false,
1235
+ expected: "{ enabled: boolean }",
1236
+ received: describeArgs(args)
1237
+ };
1238
+ return { ok: true };
1239
+ },
1240
+ example: "call_sdk('setScreenAwakeMode', [{ enabled: true }])"
1241
+ },
1242
+ {
1243
+ name: "getOperationalEnvironment",
1244
+ validateArgs(_args) {
1245
+ return { ok: true };
1246
+ },
1247
+ example: "call_sdk('getOperationalEnvironment', [])"
1248
+ },
1249
+ {
1250
+ name: "getPlatformOS",
1251
+ validateArgs(_args) {
1252
+ return { ok: true };
1253
+ },
1254
+ example: "call_sdk('getPlatformOS', [])"
1255
+ },
1256
+ {
1257
+ name: "getDeviceId",
1258
+ validateArgs(_args) {
1259
+ return { ok: true };
1260
+ },
1261
+ example: "call_sdk('getDeviceId', [])"
1262
+ },
1263
+ {
1264
+ name: "getLocale",
1265
+ validateArgs(_args) {
1266
+ return { ok: true };
1267
+ },
1268
+ example: "call_sdk('getLocale', [])"
1269
+ },
1270
+ {
1271
+ name: "getNetworkStatus",
1272
+ validateArgs(_args) {
1273
+ return { ok: true };
1274
+ },
1275
+ example: "call_sdk('getNetworkStatus', [])"
1276
+ },
1277
+ {
1278
+ name: "getSchemeUri",
1279
+ validateArgs(_args) {
1280
+ return { ok: true };
1281
+ },
1282
+ example: "call_sdk('getSchemeUri', [])"
1283
+ },
1284
+ {
1285
+ name: "requestReview",
1286
+ validateArgs(_args) {
1287
+ return { ok: true };
1288
+ },
1289
+ example: "call_sdk('requestReview', [])"
1290
+ },
1291
+ {
1292
+ name: "closeView",
1293
+ validateArgs(_args) {
1294
+ return { ok: true };
1295
+ },
1296
+ example: "call_sdk('closeView', [])"
1297
+ }
1298
+ ];
1299
+ const SIGNATURE_MAP = new Map(SIGNATURES.map((s) => [s.name, s]));
1300
+ /** 세션 내 passthrough 경고를 한 번만 emit하기 위한 Set */
1301
+ const _warnedPassthrough = /* @__PURE__ */ new Set();
1302
+ /**
1303
+ * 메서드 이름으로 시그니처를 조회한다.
1304
+ * 등록된 메서드이면 `SdkSignature`를 반환하고, 미등록이면 `undefined`.
1305
+ */
1306
+ function lookupSignature(name) {
1307
+ return SIGNATURE_MAP.get(name);
1308
+ }
1309
+ /**
1310
+ * 미등록 메서드에 대해 stderr에 passthrough 경고를 1회 출력한다.
1311
+ * 세션 내 동일 메서드 이름은 최초 1회만 출력.
1312
+ */
1313
+ function warnPassthrough(name) {
1314
+ if (_warnedPassthrough.has(name)) return;
1315
+ _warnedPassthrough.add(name);
1316
+ process.stderr.write(`[ait-debug] call_sdk: "${name}" 시그니처가 등록되지 않음 — passthrough\n`);
1317
+ }
1318
+ SIGNATURES.map((s) => s.name);
1319
+ //#endregion
931
1320
  //#region src/mcp/tools.ts
932
1321
  /** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
933
1322
  const DEBUG_TOOL_DEFINITIONS = [
@@ -951,7 +1340,7 @@ const DEBUG_TOOL_DEFINITIONS = [
951
1340
  },
952
1341
  {
953
1342
  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.",
1343
+ description: "Returns the single active page (at most one) the relay sees attached. When a second page attaches, the previous one is evicted (last-attach wins — single-attach model). The result includes `singleAttachModel: true` so the agent knows the array is always 0 or 1 entries. Also returns whether the cloudflared tunnel is up and the public wss relay URL. Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from that target — useful to detect stale entries when the phone app backgrounded). The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint to re-attach. Call this first to confirm a page is attached before reading console/network.",
955
1344
  inputSchema: {
956
1345
  type: "object",
957
1346
  properties: {},
@@ -1028,9 +1417,21 @@ const DEBUG_TOOL_DEFINITIONS = [
1028
1417
  required: ["expression"]
1029
1418
  }
1030
1419
  },
1420
+ {
1421
+ name: "list_exceptions",
1422
+ 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.",
1423
+ inputSchema: {
1424
+ type: "object",
1425
+ properties: { limit: {
1426
+ type: "number",
1427
+ description: "Maximum number of exceptions to return (default 50, max 50)."
1428
+ } },
1429
+ required: []
1430
+ }
1431
+ },
1031
1432
  {
1032
1433
  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).",
1434
+ 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
1435
  inputSchema: {
1035
1436
  type: "object",
1036
1437
  properties: {
@@ -1133,10 +1534,58 @@ function listNetworkRequests(connection) {
1133
1534
  };
1134
1535
  });
1135
1536
  }
1537
+ /** Formats a single CDP call frame into `at fn (url:line:col)`. */
1538
+ function formatCallFrame(frame) {
1539
+ return `at ${frame.functionName || "(anonymous)"} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`;
1540
+ }
1541
+ /** Normalizes a raw `Runtime.exceptionThrown` event into a `BufferedException`. */
1542
+ function normalizeException(event) {
1543
+ const { timestamp, exceptionDetails } = event;
1544
+ const frames = exceptionDetails.stackTrace?.callFrames;
1545
+ const stack = frames && frames.length > 0 ? frames.map(formatCallFrame).join("\n") : void 0;
1546
+ const exceptionText = exceptionDetails.exception?.description ?? void 0;
1547
+ const result = {
1548
+ timestamp,
1549
+ text: exceptionDetails.text,
1550
+ raw: event
1551
+ };
1552
+ if (exceptionDetails.url !== void 0) result.url = exceptionDetails.url;
1553
+ if (exceptionDetails.lineNumber !== void 0) result.lineNumber = exceptionDetails.lineNumber;
1554
+ if (exceptionDetails.columnNumber !== void 0) result.columnNumber = exceptionDetails.columnNumber;
1555
+ if (exceptionText !== void 0) result.exceptionText = exceptionText;
1556
+ if (stack !== void 0) result.stack = stack;
1557
+ return result;
1558
+ }
1559
+ /**
1560
+ * Returns the most recent buffered `Runtime.exceptionThrown` events, normalized.
1561
+ * Oldest-first; limited to `limit` entries (default 50, max 50).
1562
+ */
1563
+ function listExceptions(connection, limit = 50) {
1564
+ const cap = Math.min(Math.max(1, limit), 50);
1565
+ const events = connection.getBufferedEvents("Runtime.exceptionThrown");
1566
+ return (events.length > cap ? events.slice(events.length - cap) : events).map((e) => normalizeException(e));
1567
+ }
1568
+ function isCrashAware(conn) {
1569
+ return typeof conn.getLastCrashDetectedAt === "function" && typeof conn.getTargetLastSeenAt === "function";
1570
+ }
1136
1571
  function listPages(connection, tunnel) {
1572
+ const pages = connection.listTargets().map((t) => {
1573
+ const lastSeenMs = isCrashAware(connection) ? connection.getTargetLastSeenAt(t.id) : null;
1574
+ return {
1575
+ id: t.id,
1576
+ title: t.title,
1577
+ url: t.url,
1578
+ lastSeenAt: lastSeenMs !== null ? new Date(lastSeenMs).toISOString() : null
1579
+ };
1580
+ });
1581
+ const crashMs = isCrashAware(connection) ? connection.getLastCrashDetectedAt() : null;
1582
+ const crashDetectedAt = crashMs !== null ? new Date(crashMs).toISOString() : null;
1137
1583
  return {
1138
- pages: connection.listTargets(),
1139
- tunnel
1584
+ pages,
1585
+ tunnel,
1586
+ crashDetectedAt,
1587
+ crashWarning: crashDetectedAt ? `[ait-debug] page crash 감지됨 — 새 attach 필요 (관측 시각: ${crashDetectedAt})` : null,
1588
+ singleAttachModel: true
1140
1589
  };
1141
1590
  }
1142
1591
  /**
@@ -1542,29 +1991,73 @@ function normalizeCallSdkResult(rawValue) {
1542
1991
  throw new Error("call_sdk: bridge result missing \"ok\" field");
1543
1992
  }
1544
1993
  /**
1994
+ * Looks up the most recent exception from the buffer that falls within the
1995
+ * triage window [windowStart, windowEnd]. Returns `undefined` if none found.
1996
+ *
1997
+ * The heuristic window is:
1998
+ * - windowStart = callStart - 50ms (catch sync throws before bridge fires)
1999
+ * - windowEnd = callEnd + 200ms (catch async throws resolved soon after)
2000
+ *
2001
+ * Only the most recent exception within the window is returned (the one most
2002
+ * likely to be causally related to the SDK call).
2003
+ */
2004
+ function findRecentException(connection, windowStart, windowEnd) {
2005
+ const events = connection.getBufferedEvents("Runtime.exceptionThrown");
2006
+ for (let i = events.length - 1; i >= 0; i--) {
2007
+ const e = events[i];
2008
+ if (e.timestamp >= windowStart && e.timestamp <= windowEnd) return normalizeException(e);
2009
+ }
2010
+ }
2011
+ /**
1545
2012
  * Calls a dogfood SDK method via `window.__sdkCall` on the attached page.
1546
2013
  * NOT read-only — SDK calls may have side effects.
1547
2014
  *
1548
2015
  * On env 2/3 (real device relay) this hits the real SDK; on env 1 (local
1549
2016
  * mock) it hits the mock SDK.
1550
2017
  *
2018
+ * 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면
2019
+ * `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).
2020
+ * 미등록 메서드는 passthrough + stderr 경고 1회.
2021
+ *
1551
2022
  * Throws on CDP error or result parse failure. Returns `{ok:false, error}`
1552
- * for bridge-level errors (method not found, SDK threw, bridge absent).
2023
+ * for bridge-level errors (method not found, SDK threw, bridge absent) or
2024
+ * argument schema violations.
2025
+ *
2026
+ * If a `Runtime.exceptionThrown` event was observed within the triage window
2027
+ * [callStart-50ms, callEnd+200ms], the result includes `recentException` for
2028
+ * crash triage. This window is a heuristic — it catches the common case of an
2029
+ * SDK throw immediately before/after the bridge resolves.
1553
2030
  *
1554
2031
  * SECRET-HANDLING: name, args, and the result value are NOT written to any log.
1555
2032
  */
1556
2033
  async function callSdk(connection, name, args) {
2034
+ const signature = lookupSignature(name);
2035
+ if (signature !== void 0) {
2036
+ const validation = signature.validateArgs(args);
2037
+ if (!validation.ok) return {
2038
+ ok: false,
2039
+ error: `call_sdk("${name}") 인자 시그니처 오류.\n받음: ${validation.received}\n기대: ${validation.expected}\n올바른 예시: ${signature.example}`
2040
+ };
2041
+ } else warnPassthrough(name);
2042
+ const callStart = Date.now();
1557
2043
  const expression = buildCallSdkExpression(name, args);
1558
2044
  const result = await connection.send("Runtime.evaluate", {
1559
2045
  expression,
1560
2046
  returnByValue: true,
1561
2047
  awaitPromise: true
1562
2048
  });
2049
+ const callEnd = Date.now();
1563
2050
  if (result.exceptionDetails) {
1564
2051
  const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
1565
2052
  throw new Error(`call_sdk threw: ${msg}`);
1566
2053
  }
1567
- return normalizeCallSdkResult(result.result.value);
2054
+ const sdkResult = normalizeCallSdkResult(result.result.value);
2055
+ const recentException = findRecentException(connection, callStart - 50, callEnd + 200);
2056
+ if (recentException !== void 0) return {
2057
+ ...sdkResult,
2058
+ recentException
2059
+ };
2060
+ return sdkResult;
1568
2061
  }
1569
2062
  /** Set of tool names served by the AIT source rather than the CDP connection. */
1570
2063
  const AIT_TOOL_NAMES = new Set([
@@ -1861,7 +2354,7 @@ function createDebugServer(deps) {
1861
2354
  const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer } = deps;
1862
2355
  const server = new Server({
1863
2356
  name: "ait-debug",
1864
- version: "0.1.40"
2357
+ version: "0.1.41"
1865
2358
  }, { capabilities: { tools: { listChanged: true } } });
1866
2359
  server.setRequestHandler(ListToolsRequestSchema, () => {
1867
2360
  return { tools: connection.listTargets().length > 0 ? DEBUG_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) : DEBUG_TOOL_DEFINITIONS.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
@@ -2014,6 +2507,10 @@ function createDebugServer(deps) {
2014
2507
  try {
2015
2508
  switch (name) {
2016
2509
  case "list_console_messages": return jsonResult$1(listConsoleMessages(connection));
2510
+ case "list_exceptions": {
2511
+ const rawLimit = request.params.arguments?.limit;
2512
+ return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
2513
+ }
2017
2514
  case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
2018
2515
  case "list_pages": return jsonResult$1(listPages(connection, getTunnelStatus()));
2019
2516
  case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
@@ -2174,12 +2671,12 @@ async function runDebugServer(options = {}) {
2174
2671
  const connection = new ChiiCdpConnection({ relayBaseUrl: relay.baseUrl });
2175
2672
  const aitSource = new ChiiAitSource(connection);
2176
2673
  let qrServer;
2177
- startQrHttpServer().then((s) => {
2178
- qrServer = s;
2179
- }, (err) => {
2674
+ try {
2675
+ qrServer = await startQrHttpServer();
2676
+ } catch (err) {
2180
2677
  const message = err instanceof Error ? err.message : String(err);
2181
2678
  process.stderr.write(`[ait-debug] QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}\n`);
2182
- });
2679
+ }
2183
2680
  const server = createDebugServer({
2184
2681
  connection,
2185
2682
  aitSource,
@@ -2412,7 +2909,7 @@ function createDevServer(deps = {}) {
2412
2909
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
2413
2910
  const server = new Server({
2414
2911
  name: "ait-devtools",
2415
- version: "0.1.40"
2912
+ version: "0.1.41"
2416
2913
  }, { capabilities: { tools: {} } });
2417
2914
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
2418
2915
  server.setRequestHandler(CallToolRequestSchema, async (request) => {