@ait-co/devtools 0.1.112 → 0.1.115

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.
Files changed (47) hide show
  1. package/dist/bundle-KFs4t-wc.d.ts.map +1 -1
  2. package/dist/mcp/cli.js +218 -28
  3. package/dist/mcp/cli.js.map +1 -1
  4. package/dist/mcp/server.js +1 -1
  5. package/dist/panel/index.js +7 -1
  6. package/dist/panel/index.js.map +1 -1
  7. package/dist/{pool-mZlgCmNQ.d.ts → pool-D23t4ibd.d.ts} +2 -2
  8. package/dist/{pool-mZlgCmNQ.d.ts.map → pool-D23t4ibd.d.ts.map} +1 -1
  9. package/dist/{qr-http-server-BVS-HZjU.cjs → qr-http-server-BpTvfeku.cjs} +92 -8
  10. package/dist/qr-http-server-BpTvfeku.cjs.map +1 -0
  11. package/dist/{qr-http-server-BJJt3ush.js → qr-http-server-BsWlOA85.js} +92 -8
  12. package/dist/qr-http-server-BsWlOA85.js.map +1 -0
  13. package/dist/{qr-http-server-C1T4RNbq.cjs → qr-http-server-CrVKno9V.cjs} +92 -8
  14. package/dist/qr-http-server-CrVKno9V.cjs.map +1 -0
  15. package/dist/{qr-http-server-Cs93vEPH.js → qr-http-server-UBV2qUEd.js} +92 -8
  16. package/dist/qr-http-server-UBV2qUEd.js.map +1 -0
  17. package/dist/{relay-worker-Dppp2yZj.d.ts → relay-worker-DERQUao2.d.ts} +2 -2
  18. package/dist/{relay-worker-Dppp2yZj.d.ts.map → relay-worker-DERQUao2.d.ts.map} +1 -1
  19. package/dist/runtime-BKMMoeMj.d.ts +153 -0
  20. package/dist/runtime-BKMMoeMj.d.ts.map +1 -0
  21. package/dist/test-runner/bundle.js +124 -18
  22. package/dist/test-runner/bundle.js.map +1 -1
  23. package/dist/test-runner/cli.js +125 -19
  24. package/dist/test-runner/cli.js.map +1 -1
  25. package/dist/test-runner/config.d.ts +1 -1
  26. package/dist/test-runner/pool.d.ts +1 -1
  27. package/dist/test-runner/relay-worker.d.ts +1 -1
  28. package/dist/test-runner/rpc.d.ts +1 -1
  29. package/dist/test-runner/runtime.d.ts +2 -2
  30. package/dist/test-runner/runtime.js +296 -18
  31. package/dist/test-runner/runtime.js.map +1 -1
  32. package/dist/test-runner/task-graph.d.ts +1 -1
  33. package/dist/{tunnel-Cpn3mA4u.js → tunnel-B7U5k1xa.js} +2 -2
  34. package/dist/{tunnel-Cpn3mA4u.js.map → tunnel-B7U5k1xa.js.map} +1 -1
  35. package/dist/{tunnel-Dj8Kf2QS.cjs → tunnel-C-cgMnyY.cjs} +2 -2
  36. package/dist/{tunnel-Dj8Kf2QS.cjs.map → tunnel-C-cgMnyY.cjs.map} +1 -1
  37. package/dist/unplugin/index.cjs +1 -1
  38. package/dist/unplugin/index.js +1 -1
  39. package/dist/unplugin/tunnel.cjs +1 -1
  40. package/dist/unplugin/tunnel.js +1 -1
  41. package/package.json +1 -1
  42. package/dist/qr-http-server-BJJt3ush.js.map +0 -1
  43. package/dist/qr-http-server-BVS-HZjU.cjs.map +0 -1
  44. package/dist/qr-http-server-C1T4RNbq.cjs.map +0 -1
  45. package/dist/qr-http-server-Cs93vEPH.js.map +0 -1
  46. package/dist/runtime-C7uxh3Mf.d.ts +0 -62
  47. package/dist/runtime-C7uxh3Mf.d.ts.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"bundle-KFs4t-wc.d.ts","names":[],"sources":["../src/test-runner/bundle.ts"],"mappings":";;AAqEA;;;;;AAmBA;;;;;AA2LA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UA9MiB,aAAA;;;;;;;EAOf,cAAA;;;;;EAKA,UAAA;AAAA;;;;;UAOe,YAAA;EACf,IAAA;EACA,QAAA;AAAA;;;;;;;;;;;;;;;iBAyLoB,cAAA,CAAe,OAAA,UAAiB,IAAA,GAAO,aAAA,GAAgB,OAAA,CAAQ,YAAA"}
1
+ {"version":3,"file":"bundle-KFs4t-wc.d.ts","names":[],"sources":["../src/test-runner/bundle.ts"],"mappings":";;AAqEA;;;;;AAmBA;;;;;AA8TA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAjViB,aAAA;;;;;;;EAOf,cAAA;;;;;EAKA,UAAA;AAAA;;;;;UAOe,YAAA;EACf,IAAA;EACA,QAAA;AAAA;;;;;;;;;;;;;;;iBA4ToB,cAAA,CAAe,OAAA,UAAiB,IAAA,GAAO,aAAA,GAAgB,OAAA,CAAQ,YAAA"}
package/dist/mcp/cli.js CHANGED
@@ -298,12 +298,31 @@ async function discoverTestFiles(patterns, cwd) {
298
298
  *
299
299
  * SECRET-HANDLING: the returned bundle code is caller-managed; never log it.
300
300
  */
301
+ /** The SDK package name that mini-app test code imports from. */
302
+ const SDK_PACKAGE = "@apps-in-toss/web-framework";
303
+ /**
304
+ * Names the runtime installs as globals before invoking the user factory.
305
+ * The `vitest` virtual module re-exports each as a lazy getter that reads from
306
+ * `globalThis` at access time. Keep in sync with the globals installed in
307
+ * `runtime.ts#runTestModule`.
308
+ */
309
+ const VITEST_GLOBAL_NAMES = [
310
+ "describe",
311
+ "it",
312
+ "test",
313
+ "expect",
314
+ "beforeAll",
315
+ "afterAll",
316
+ "beforeEach",
317
+ "afterEach",
318
+ "vi"
319
+ ];
301
320
  /**
302
321
  * Matches the bare SDK package and any sub-path import
303
322
  * (`@apps-in-toss/web-framework`, `@apps-in-toss/web-framework/foo`).
304
323
  * Built from {@link SDK_PACKAGE} so the package name has a single source.
305
324
  */
306
- const SDK_IMPORT_FILTER = new RegExp(`^${"@apps-in-toss/web-framework".replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
325
+ const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
307
326
  /**
308
327
  * esbuild plugin that intercepts SDK imports and redirects them to the
309
328
  * `window.__sdk` proxy that `src/in-app/auto.ts` installs at runtime.
@@ -344,6 +363,53 @@ module.exports = __proxy;
344
363
  };
345
364
  }
346
365
  /**
366
+ * esbuild plugin that intercepts `import … from 'vitest'` and replaces it with
367
+ * a virtual module that delegates every named import to `globalThis` at ACCESS
368
+ * time (not at bundle-evaluation time).
369
+ *
370
+ * The runtime installs `describe/it/test/expect/beforeAll/afterAll/beforeEach/
371
+ * afterEach/vi` as globals inside `runTestModule`, which runs AFTER the bundle
372
+ * IIFE is evaluated. A value-copy redirect (`export var describe =
373
+ * globalThis.describe`) would therefore capture `undefined` at evaluation time
374
+ * and the user's `describe(...)` calls would be no-ops — registering zero tests.
375
+ *
376
+ * The fix defers the lookup to call time using per-name **getter** exports.
377
+ * We emit a CommonJS module that:
378
+ * 1. sets `__esModule = true` so esbuild's `__toESM` interop maps each named
379
+ * import directly to a property access on the module (NOT wrapped under a
380
+ * `default` shim — which is what happens for a bare Proxy whose own-keys
381
+ * are empty, leaving every named import `undefined`);
382
+ * 2. defines each global name as a getter that reads `globalThis[name]` on
383
+ * every access. So `import { describe } from 'vitest'` compiles to
384
+ * `import_vitest.describe`, whose getter returns the real `describe` only
385
+ * when the factory calls it — after `runTestModule` installs the globals.
386
+ *
387
+ * A plain `module.exports = new Proxy(...)` does NOT work here: esbuild routes
388
+ * the virtual module through `__toESM`, which enumerates own-keys (none on an
389
+ * empty Proxy target) and therefore exposes zero named exports. Explicit getter
390
+ * properties give `__toESM` real keys to map while keeping access lazy.
391
+ */
392
+ function vitestRedirectPlugin() {
393
+ return {
394
+ name: "vitest-redirect",
395
+ setup(build) {
396
+ build.onResolve({ filter: /^vitest$/ }, () => ({
397
+ path: "vitest",
398
+ namespace: "vitest-redirect"
399
+ }));
400
+ build.onLoad({
401
+ filter: /^vitest$/,
402
+ namespace: "vitest-redirect"
403
+ }, () => {
404
+ return {
405
+ contents: `Object.defineProperty(exports, '__esModule', { value: true });\n${VITEST_GLOBAL_NAMES.map((name) => `Object.defineProperty(exports, ${JSON.stringify(name)}, { enumerable: true, get: function() { return globalThis[${JSON.stringify(name)}]; } });`).join("\n")}\n`,
406
+ loader: "js"
407
+ };
408
+ });
409
+ }
410
+ };
411
+ }
412
+ /**
347
413
  * esbuild plugin that transforms the user test file into a module that exports
348
414
  * an async `__userFactory` function. The factory defers the user's top-level
349
415
  * test registration code (describe/it/test calls) so it only runs when
@@ -351,8 +417,19 @@ module.exports = __proxy;
351
417
  * installed describe/it/test/expect as globals.
352
418
  *
353
419
  * Algorithm:
354
- * - Lines matching import declarations or re-export statements are kept at
355
- * module top-level (the only valid ESM position for static `import`).
420
+ * - Import declarations and re-export statements are kept at module top-level
421
+ * (the only valid ESM position for static `import`). A statement that spans
422
+ * multiple lines — e.g. a named import with one member per line:
423
+ * import {
424
+ * appLogin,
425
+ * getAnonymousKey,
426
+ * } from '@apps-in-toss/web-framework';
427
+ * is tracked as a single block: every line from the opening `import {` /
428
+ * `export {` through the closing `from '…'` (or side-effect `'…'`) line is
429
+ * kept together at top-level. This prevents the member lines and the
430
+ * closing `} from '…'` line from leaking into the factory body, which would
431
+ * leave an unterminated `import {` at module scope (the #678 env3 failure:
432
+ * esbuild threw `Expected "as" but found "{"` on multi-line SDK imports).
356
433
  * - All other lines (describe/it/test calls, local declarations, etc.) are
357
434
  * moved into the body of the exported async factory function.
358
435
  *
@@ -376,12 +453,25 @@ function userFactoryPlugin(absPath) {
376
453
  const topLevelLines = [];
377
454
  const bodyLines = [];
378
455
  const EXPORT_DECLARATION_RE = /^(export\s+)(default\s+|async\s+function\s+|function\s+|class\s+|const\s+|let\s+|var\s+)/;
456
+ const isImportStart = (trimmed) => trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"");
457
+ const endsStatement = (trimmed) => /['"]\s*;?\s*$/.test(trimmed.replace(/\/\/.*$/, "").trimEnd());
458
+ let inImportBlock = false;
379
459
  for (const line of lines) {
380
460
  const trimmed = line.trimStart();
381
461
  const indent = line.slice(0, line.length - trimmed.length);
382
- if (trimmed.startsWith("import ") || trimmed.startsWith("import{") || trimmed.startsWith("import'") || trimmed.startsWith("import\"")) topLevelLines.push(line);
383
- else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
384
- else topLevelLines.push(line);
462
+ if (inImportBlock) {
463
+ topLevelLines.push(line);
464
+ if (endsStatement(trimmed)) inImportBlock = false;
465
+ continue;
466
+ }
467
+ if (isImportStart(trimmed)) {
468
+ topLevelLines.push(line);
469
+ if (!endsStatement(trimmed)) inImportBlock = true;
470
+ } else if (trimmed.startsWith("export ")) if (trimmed.match(EXPORT_DECLARATION_RE)) bodyLines.push(indent + trimmed.slice(7));
471
+ else {
472
+ topLevelLines.push(line);
473
+ if (/\bfrom\b/.test(trimmed) ? !endsStatement(trimmed) : trimmed.endsWith("{")) inImportBlock = true;
474
+ }
385
475
  else bodyLines.push(line);
386
476
  }
387
477
  return {
@@ -401,21 +491,33 @@ function userFactoryPlugin(absPath) {
401
491
  };
402
492
  }
403
493
  /**
404
- * Returns the absolute path to the co-located runtime module.
494
+ * Returns the absolute path to the test-runner runtime module.
495
+ *
496
+ * Searches candidates in priority order:
497
+ * 1. Co-located `runtime.ts` / `runtime.js` — covers the source tree
498
+ * (tsx / ts-node) and the `dist/test-runner/` entry.
499
+ * 2. `../test-runner/runtime.js` — covers the `dist/mcp/cli.js` entry,
500
+ * where `import.meta.url` resolves to `dist/mcp/` (a sibling directory
501
+ * of `dist/test-runner/`). Without this second candidate the MCP entry
502
+ * point would look for `dist/mcp/runtime.js`, which does not exist, and
503
+ * every `run_tests` call would fail with an esbuild "Could not resolve"
504
+ * error (#678).
405
505
  *
406
- * In the source tree (running via tsx / ts-node) the file is `runtime.ts`.
407
- * After `tsdown` compiles to `dist/test-runner/`, it becomes `runtime.js`.
408
- * We try both extensions to support both environments.
506
+ * Returns the first candidate that exists on disk. Falls back to the
507
+ * co-located `runtime.js` path so esbuild produces a clear "file not found"
508
+ * error rather than a cryptic failure.
409
509
  */
410
510
  function getRuntimePath() {
411
511
  const dir = path.dirname(fileURLToPath(import.meta.url));
412
- for (const ext of [".ts", ".js"]) {
413
- const candidate = path.join(dir, `runtime${ext}`);
414
- try {
415
- accessSync(candidate);
416
- return candidate;
417
- } catch {}
418
- }
512
+ const candidates = [
513
+ path.join(dir, "runtime.ts"),
514
+ path.join(dir, "runtime.js"),
515
+ path.join(dir, "..", "test-runner", "runtime.js")
516
+ ];
517
+ for (const candidate of candidates) try {
518
+ accessSync(candidate);
519
+ return candidate;
520
+ } catch {}
419
521
  return path.join(dir, "runtime.js");
420
522
  }
421
523
  /**
@@ -454,7 +556,11 @@ async function bundleTestFile(absPath, opts) {
454
556
  platform: "browser",
455
557
  target: "es2022",
456
558
  write: false,
457
- plugins: [userFactoryPlugin(absPath), sdkRedirectPlugin()],
559
+ plugins: [
560
+ userFactoryPlugin(absPath),
561
+ vitestRedirectPlugin(),
562
+ sdkRedirectPlugin()
563
+ ],
458
564
  external: extraExternals,
459
565
  treeShaking: true,
460
566
  footer: { js: `globalThis[${JSON.stringify(globalName)}] = ${globalName};` }
@@ -2731,6 +2837,9 @@ const en = {
2731
2837
  "dashboard.inspector.waiting": "Attach a page to enable the \"Open DevTools\" button",
2732
2838
  "inspector.error.noTarget": "No page attached. Attach a device and try again.",
2733
2839
  "inspector.error.relayDown": "Relay is not active. Start a relay session first.",
2840
+ "dashboard.watchdog.title": "Server has shut down",
2841
+ "dashboard.watchdog.body": "The MCP server has stopped. This session is no longer active. You may close this tab.",
2842
+ "dashboard.watchdog.close": "Close tab",
2734
2843
  "attach.title": "AIT Debug Session — QR Scan",
2735
2844
  "attach.deployment": "deployment: {label}",
2736
2845
  "attach.steps.section": "How to scan",
@@ -2966,6 +3075,9 @@ const tables = {
2966
3075
  "dashboard.inspector.waiting": "페이지를 attach하면 \"디버그 툴 열기\" 버튼이 표시됩니다",
2967
3076
  "inspector.error.noTarget": "연결된 페이지가 없습니다. 기기를 attach한 후 다시 시도하세요.",
2968
3077
  "inspector.error.relayDown": "relay가 활성화되지 않았습니다. start_debug로 relay를 기동하세요.",
3078
+ "dashboard.watchdog.title": "서버가 종료되었습니다",
3079
+ "dashboard.watchdog.body": "MCP server가 종료되어 이 세션이 더 이상 유효하지 않습니다. 이 탭을 닫아도 됩니다.",
3080
+ "dashboard.watchdog.close": "탭 닫기",
2969
3081
  "attach.title": "AIT 디버그 세션 — QR 스캔",
2970
3082
  "attach.deployment": "deployment: {label}",
2971
3083
  "attach.steps.section": "스캔 절차",
@@ -3513,7 +3625,7 @@ function buildLangSwitcher(path, existingParams, locale, s) {
3513
3625
  * - tunnel wssUrl은 "터널 연결됨" 상태 표시에서 UP/DOWN만 노출.
3514
3626
  * wssUrl 값 자체는 dashboard HTML에 넣지 않는다.
3515
3627
  */
3516
- function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new URLSearchParams(), devtoolsEntryUrl = null) {
3628
+ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new URLSearchParams(), devtoolsEntryUrl = null, sseRefreshIntervalMs = 9e4) {
3517
3629
  const s = resolveLocaleStrings(locale);
3518
3630
  const now = (/* @__PURE__ */ new Date()).toISOString();
3519
3631
  const tunnelStatus = state.tunnel.up ? s("dashboard.tunnel.up") : s("dashboard.tunnel.down");
@@ -3532,6 +3644,7 @@ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new U
3532
3644
  const pagesSection = state.pages === null ? "" : `<hr /><section id="pages-section"><h2>${escapeHtml(s("dashboard.pages.section"))}</h2><ul id="pages-list">${state.pages.length > 0 ? state.pages.map((p) => {
3533
3645
  return `<li><span class="page-id">${escapeHtml(p.id)}</span> <span class="page-url">${escapeHtml(p.url.slice(0, 120))}</span></li>`;
3534
3646
  }).join("\n") : `<li class="empty">${escapeHtml(s("dashboard.pages.empty"))}</li>`}</ul></section>`;
3647
+ const watchdogTimeoutMs = sseRefreshIntervalMs < 5e3 ? sseRefreshIntervalMs * 3 : sseRefreshIntervalMs * 2 + 3e4;
3535
3648
  const sseStrings = {
3536
3649
  tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
3537
3650
  tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
@@ -3542,7 +3655,11 @@ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new U
3542
3655
  copiedLabel: JSON.stringify(s("dashboard.url.copied")),
3543
3656
  inspectorOpenLabel: JSON.stringify(s("dashboard.inspector.open")),
3544
3657
  inspectorWaitingLabel: JSON.stringify(s("dashboard.inspector.waiting")),
3545
- dashboardSurface: true
3658
+ dashboardSurface: true,
3659
+ watchdogTimeoutMs,
3660
+ watchdogTitle: JSON.stringify(s("dashboard.watchdog.title")),
3661
+ watchdogBody: JSON.stringify(s("dashboard.watchdog.body")),
3662
+ watchdogCloseLabel: JSON.stringify(s("dashboard.watchdog.close"))
3546
3663
  };
3547
3664
  const langSwitcher = buildLangSwitcher(path, params, locale, s);
3548
3665
  const filled = dashboardChromeByLocale[locale].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__NOW__", escapeHtml(now)).replaceAll("__TUNNEL_CLASS__", tunnelClass).replaceAll("__TUNNEL_STATUS__", escapeHtml(tunnelStatus)).replaceAll("__ATTACH_SECTION__", attachSection).replaceAll("__INSPECTOR_SECTION__", inspectorSection).replaceAll("__PAGES_SECTION__", pagesSection);
@@ -3568,6 +3685,10 @@ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new U
3568
3685
  * - clipboard: navigator.clipboard.writeText → 실패/부재 시 textarea execCommand fallback.
3569
3686
  * - 피드백: 버튼 라벨이 COPIED_LABEL로 ~1.5초 전환 후 COPY_LABEL로 복귀.
3570
3687
  *
3688
+ * SSE watchdog (#681): SSE 주기 갱신이 `watchdogTimeoutMs` 동안 수신되지 않으면
3689
+ * server가 종료된 것으로 판단한다. `window.close()`를 시도하고, 닫히지 않으면
3690
+ * 폴백 안내 화면으로 교체해 stale QR이 계속 보이는 오해를 항상 방지한다.
3691
+ *
3571
3692
  * 문자열 인자는 빌드타임에 ko/en 테이블에서 가져와 JSON.stringify로 이미 escape됨.
3572
3693
  *
3573
3694
  * SECRET-HANDLING: URL 값을 console.log 등으로 출력하지 않는다.
@@ -3587,6 +3708,14 @@ function buildSseScript(strings) {
3587
3708
  var INSPECTOR_OPEN_LABEL = ${strings.inspectorOpenLabel};
3588
3709
  var INSPECTOR_WAITING_LABEL = ${strings.inspectorWaitingLabel};
3589
3710
 
3711
+ // ── SSE watchdog 상수 (#681) ──────────────────────────────────────────
3712
+ // server의 SSE refresh 주기(기본 90s)보다 넉넉히 큰 값 — 주기 × 2 + 30s 여유.
3713
+ // token-fill 패턴으로 server에서 계산해 주입한다.
3714
+ var WATCHDOG_TIMEOUT_MS = ${strings.watchdogTimeoutMs};
3715
+ var WATCHDOG_TITLE = ${strings.watchdogTitle};
3716
+ var WATCHDOG_BODY = ${strings.watchdogBody};
3717
+ var WATCHDOG_CLOSE_LABEL = ${strings.watchdogCloseLabel};
3718
+
3590
3719
  // ── 클립보드 복사 헬퍼 ────────────────────────────────────────────────
3591
3720
  function copyText(text) {
3592
3721
  if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -3641,9 +3770,65 @@ function buildSseScript(strings) {
3641
3770
  }
3642
3771
  });
3643
3772
 
3773
+ // ── SSE watchdog (#681) ───────────────────────────────────────────────
3774
+ // 마지막으로 SSE 메시지를 수신한 시각. 스크립트 로드 시각으로 초기화한다.
3775
+ // (서버가 /events 연결 즉시 초기 상태를 push하므로 onmessage가 곧 갱신한다.)
3776
+ var lastSeen = Date.now();
3777
+ // watchdog이 한 번 발화하면 타이머를 clear해 중복 실행 방지.
3778
+ var watchdogFired = false;
3779
+ var watchdogTimer = null;
3780
+
3781
+ // 폴백 UI — window.close()가 무시되면 stale QR 대신 안내 화면을 표시한다.
3782
+ function showWatchdogFallback() {
3783
+ if (document.body) {
3784
+ document.body.innerHTML =
3785
+ '<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;' +
3786
+ 'height:100vh;font-family:sans-serif;text-align:center;padding:2rem;box-sizing:border-box;">' +
3787
+ '<p style="font-size:1.4rem;font-weight:bold;margin-bottom:1rem;">' + WATCHDOG_TITLE + '</p>' +
3788
+ '<p style="color:#555;margin-bottom:2rem;">' + WATCHDOG_BODY + '</p>' +
3789
+ '<button onclick="window.close()" ' +
3790
+ 'style="padding:0.6rem 1.4rem;font-size:1rem;cursor:pointer;border:1px solid #ccc;' +
3791
+ 'border-radius:6px;background:#f5f5f5;">' + WATCHDOG_CLOSE_LABEL + '</button>' +
3792
+ '</div>';
3793
+ }
3794
+ }
3795
+
3796
+ function fireWatchdog() {
3797
+ if (watchdogFired) return;
3798
+ watchdogFired = true;
3799
+ if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; }
3800
+ // 폴백 UI를 먼저 표시한다 — close가 성공하면 어차피 페이지가 닫혀 보이지 않는다.
3801
+ // close 실패(opener 없는 탭)에 대비해 stale QR 대신 안내 화면을 항상 보장한다.
3802
+ showWatchdogFallback();
3803
+ // window.close() 시도 — OS가 연 탭은 opener가 없어 무시될 수 있다.
3804
+ window.close();
3805
+ }
3806
+
3807
+ // 주기적으로 lastSeen을 검사 — WATCHDOG_TIMEOUT_MS 초과 시 발화.
3808
+ // 검사 간격: WATCHDOG_TIMEOUT_MS / 6, 단 최소 1s 이상이되 timeout 자체보다 크지 않게 한다.
3809
+ // (sseRefreshIntervalMs가 작을 때 최솟값 1s가 timeout을 초과하는 경우 방지 — 테스트 격리.)
3810
+ var checkIntervalMs = Math.min(
3811
+ Math.max(1000, Math.round(WATCHDOG_TIMEOUT_MS / 6)),
3812
+ WATCHDOG_TIMEOUT_MS,
3813
+ );
3814
+ watchdogTimer = setInterval(function () {
3815
+ if (watchdogFired) return;
3816
+ if (Date.now() - lastSeen > WATCHDOG_TIMEOUT_MS) {
3817
+ fireWatchdog();
3818
+ }
3819
+ }, checkIntervalMs);
3820
+
3644
3821
  // ── SSE 구독 ──────────────────────────────────────────────────────────
3645
3822
  var src = new EventSource('/events');
3823
+ src.onopen = function () {
3824
+ // 연결 복구 시 lastSeen 갱신 — 일시적 끊김이 watchdog을 발화시키지 않게.
3825
+ // watchdog이 이미 발화했으면 갱신하지 않는다(폴백 UI를 그대로 유지).
3826
+ if (!watchdogFired) { lastSeen = Date.now(); }
3827
+ };
3646
3828
  src.onmessage = function (e) {
3829
+ // SSE 메시지 수신 — lastSeen 갱신으로 watchdog 리셋.
3830
+ // watchdog 발화 후라면 갱신해도 타이머가 이미 clear됐으므로 효과 없음.
3831
+ if (!watchdogFired) { lastSeen = Date.now(); }
3647
3832
  try {
3648
3833
  var s = JSON.parse(e.data);
3649
3834
  // 터널 상태 갱신
@@ -3750,7 +3935,7 @@ function buildSseScript(strings) {
3750
3935
  * SECRET-HANDLING: TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — 의도된 transport.
3751
3936
  * inspectorStableUrl은 /inspector 안정 URL (127.0.0.1, 시크릿 없음) — 노출 가능.
3752
3937
  */
3753
- function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/attach", params = new URLSearchParams(), mode, pagesAttached = false, inspectorStableUrl = null) {
3938
+ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/attach", params = new URLSearchParams(), mode, pagesAttached = false, inspectorStableUrl = null, sseRefreshIntervalMs = 9e4) {
3754
3939
  const s = resolveLocaleStrings(locale);
3755
3940
  const langSwitcher = buildLangSwitcher(path, params, locale, s);
3756
3941
  const family = attachFamilyForMode(mode);
@@ -3758,6 +3943,7 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/a
3758
3943
  if (pagesAttached && inspectorStableUrl) inspectorSection = `<a class="inspector-link" id="inspector-link" href="${escapeHtml(inspectorStableUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(s("dashboard.inspector.open"))}</a>`;
3759
3944
  else inspectorSection = `<span class="inspector-hint" id="inspector-link">${escapeHtml(s("dashboard.inspector.waiting"))}</span>`;
3760
3945
  const filled = attachChromeByLocale[locale][family].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__MODE_LABEL__", buildModeLabel(mode, s)).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl).replaceAll("__INSPECTOR_SECTION__", inspectorSection);
3946
+ const watchdogTimeoutMs = sseRefreshIntervalMs < 5e3 ? sseRefreshIntervalMs * 3 : sseRefreshIntervalMs * 2 + 3e4;
3761
3947
  const sseScript = buildSseScript({
3762
3948
  tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
3763
3949
  tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
@@ -3768,7 +3954,11 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/a
3768
3954
  copiedLabel: JSON.stringify(s("dashboard.url.copied")),
3769
3955
  inspectorOpenLabel: JSON.stringify(s("dashboard.inspector.open")),
3770
3956
  inspectorWaitingLabel: JSON.stringify(s("dashboard.inspector.waiting")),
3771
- dashboardSurface: false
3957
+ dashboardSurface: false,
3958
+ watchdogTimeoutMs,
3959
+ watchdogTitle: JSON.stringify(s("dashboard.watchdog.title")),
3960
+ watchdogBody: JSON.stringify(s("dashboard.watchdog.body")),
3961
+ watchdogCloseLabel: JSON.stringify(s("dashboard.watchdog.close"))
3772
3962
  });
3773
3963
  return filled.replace("</body>", `${sseScript}\n</body>`);
3774
3964
  }
@@ -3785,6 +3975,7 @@ async function startQrHttpServer(getDashboardState, options) {
3785
3975
  const { default: QRCode } = await import("qrcode");
3786
3976
  /** SSE 활성 연결 목록 — `notifyStateChange()` 시 전체 push. */
3787
3977
  const sseClients = [];
3978
+ const refreshIntervalMs = options?.sseRefreshIntervalMs ?? 9e4;
3788
3979
  /** SSE 연결 하나에 상태 이벤트를 flush한다. */
3789
3980
  function pushStateToClient(res, state) {
3790
3981
  const payload = JSON.stringify({
@@ -3823,7 +4014,7 @@ async function startQrHttpServer(getDashboardState, options) {
3823
4014
  if (!addr || typeof addr === "string") return null;
3824
4015
  return `http://127.0.0.1:${addr.port}/devtools/`;
3825
4016
  })();
3826
- const html = buildDashboardHtml(state, qrDataUrl, locale, path, params, devtoolsEntryUrl);
4017
+ const html = buildDashboardHtml(state, qrDataUrl, locale, path, params, devtoolsEntryUrl, refreshIntervalMs);
3827
4018
  res.writeHead(200, {
3828
4019
  "Content-Type": "text/html; charset=utf-8",
3829
4020
  "Cache-Control": "no-store"
@@ -3879,7 +4070,7 @@ async function startQrHttpServer(getDashboardState, options) {
3879
4070
  type: "image/png",
3880
4071
  errorCorrectionLevel: "M"
3881
4072
  }).then((dataUrl) => {
3882
- const html = buildAttachHtml(dataUrl, escapeHtml(deploymentIdLabel), escapeHtml(attachUrl), locale, path, params, mode, pagesAttached, inspectorStableUrlForAttach);
4073
+ const html = buildAttachHtml(dataUrl, escapeHtml(deploymentIdLabel), escapeHtml(attachUrl), locale, path, params, mode, pagesAttached, inspectorStableUrlForAttach, refreshIntervalMs);
3883
4074
  res.writeHead(200, {
3884
4075
  "Content-Type": "text/html; charset=utf-8",
3885
4076
  "Cache-Control": "no-store"
@@ -3962,7 +4153,6 @@ async function startQrHttpServer(getDashboardState, options) {
3962
4153
  pushStateToClient(client, state);
3963
4154
  } catch {}
3964
4155
  }
3965
- const refreshIntervalMs = options?.sseRefreshIntervalMs ?? 9e4;
3966
4156
  const refreshHandle = setInterval(() => {
3967
4157
  if (sseClients.length > 0 && getDashboardState) notifyStateChangeInternal();
3968
4158
  }, refreshIntervalMs).unref();
@@ -5375,7 +5565,7 @@ async function readMcpSdkVersion() {
5375
5565
  * some test environments that skip the build step).
5376
5566
  */
5377
5567
  function readDevtoolsVersion() {
5378
- return "0.1.112";
5568
+ return "0.1.115";
5379
5569
  }
5380
5570
  /**
5381
5571
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -6295,7 +6485,7 @@ function createDebugServer(deps) {
6295
6485
  }
6296
6486
  const server = new Server({
6297
6487
  name: "ait-debug",
6298
- version: "0.1.112"
6488
+ version: "0.1.115"
6299
6489
  }, { capabilities: { tools: { listChanged: true } } });
6300
6490
  server.setRequestHandler(ListToolsRequestSchema, () => {
6301
6491
  const conn = router.active;
@@ -8195,7 +8385,7 @@ function createDevServer(deps = {}) {
8195
8385
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
8196
8386
  const server = new Server({
8197
8387
  name: "ait-devtools",
8198
- version: "0.1.112"
8388
+ version: "0.1.115"
8199
8389
  }, { capabilities: { tools: {} } });
8200
8390
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
8201
8391
  server.setRequestHandler(CallToolRequestSchema, async (request) => {