@ait-co/devtools 0.1.73 → 0.1.75

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 (38) hide show
  1. package/dist/devtools-opener-BbUXBzgA.js.map +1 -1
  2. package/dist/devtools-opener-Bp671YXu.cjs.map +1 -1
  3. package/dist/devtools-opener-D84kZFtR.js.map +1 -1
  4. package/dist/devtools-opener-h6A-UjzC.cjs.map +1 -1
  5. package/dist/mcp/cli.js +191 -76
  6. package/dist/mcp/cli.js.map +1 -1
  7. package/dist/mcp/server.js +1 -1
  8. package/dist/mock/index.d.ts +50 -2
  9. package/dist/mock/index.d.ts.map +1 -1
  10. package/dist/mock/index.js +1210 -1110
  11. package/dist/mock/index.js.map +1 -1
  12. package/dist/panel/index.js +828 -820
  13. package/dist/panel/index.js.map +1 -1
  14. package/dist/{qr-http-server-Ditd2ndz.js → qr-http-server-CDO6o2nr.js} +69 -12
  15. package/dist/qr-http-server-CDO6o2nr.js.map +1 -0
  16. package/dist/{qr-http-server-0uN5jxLW.cjs → qr-http-server-D0v9ooAD.cjs} +69 -12
  17. package/dist/qr-http-server-D0v9ooAD.cjs.map +1 -0
  18. package/dist/{qr-http-server-TQG61eI4.js → qr-http-server-DznDIcJF.js} +69 -12
  19. package/dist/qr-http-server-DznDIcJF.js.map +1 -0
  20. package/dist/{qr-http-server-BTjpFS3p.cjs → qr-http-server-jMC1nVqY.cjs} +69 -12
  21. package/dist/qr-http-server-jMC1nVqY.cjs.map +1 -0
  22. package/dist/{tunnel-BXAWl2tI.cjs → tunnel-D7f-0enB.cjs} +3 -2
  23. package/dist/{tunnel-BXAWl2tI.cjs.map → tunnel-D7f-0enB.cjs.map} +1 -1
  24. package/dist/{tunnel-BxGnLAat.js → tunnel-km3KkZrF.js} +3 -2
  25. package/dist/{tunnel-BxGnLAat.js.map → tunnel-km3KkZrF.js.map} +1 -1
  26. package/dist/unplugin/index.cjs +1 -1
  27. package/dist/unplugin/index.js +1 -1
  28. package/dist/unplugin/tunnel.cjs +2 -1
  29. package/dist/unplugin/tunnel.cjs.map +1 -1
  30. package/dist/unplugin/tunnel.d.cts.map +1 -1
  31. package/dist/unplugin/tunnel.d.ts.map +1 -1
  32. package/dist/unplugin/tunnel.js +2 -1
  33. package/dist/unplugin/tunnel.js.map +1 -1
  34. package/package.json +1 -1
  35. package/dist/qr-http-server-0uN5jxLW.cjs.map +0 -1
  36. package/dist/qr-http-server-BTjpFS3p.cjs.map +0 -1
  37. package/dist/qr-http-server-Ditd2ndz.js.map +0 -1
  38. package/dist/qr-http-server-TQG61eI4.js.map +0 -1
package/dist/mcp/cli.js CHANGED
@@ -1169,9 +1169,8 @@ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
1169
1169
  * Chii serves its own DevTools frontend at
1170
1170
  * `<relayHttpBaseUrl>/front_end/chii_app.html`. The `ws=` (plain HTTP relay)
1171
1171
  * or `wss=` (HTTPS relay) query parameter is a URL-encoded string of the form
1172
- * `<relay-host>/client/<uuid>?target=<id>` (and optionally `&at=<totp>`)
1173
- * the same format used by Chii's own target list page (derived from
1174
- * `chii/public/index.js`).
1172
+ * `<relay-host>/client/<uuid>?target=<id>&at=<totp>` — the same format used
1173
+ * by Chii's own target list page (derived from `chii/public/index.js`).
1175
1174
  *
1176
1175
  * The `at=` TOTP code is minted at call time via `mintTotp()`. It is valid
1177
1176
  * for ~3 minutes (relay gate accepts ±RELAY_VERIFY_SKEW_STEPS=6 steps =
@@ -1179,6 +1178,15 @@ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
1179
1178
  * If the window expires before the browser connects, the relay will reject the
1180
1179
  * WebSocket upgrade with close code 4401.
1181
1180
  *
1181
+ * FAIL-CLOSED (issue #509): `mintTotp` is REQUIRED. When omitted (i.e.
1182
+ * `undefined`), this function returns `null` — the caller must treat `null` as
1183
+ * "inspector not yet available" and show a waiting hint instead of a broken
1184
+ * link. Relay sessions gate every WS upgrade with TOTP (#452), so a URL built
1185
+ * without `at=` would be rejected with WS 4401 immediately — there is no
1186
+ * non-TOTP relay path in production. Returning `null` surfaces this cleanly as
1187
+ * a "TOTP not yet configured" state rather than silently producing a URL that
1188
+ * will always fail at the WS handshake.
1189
+ *
1182
1190
  * SECRET-HANDLING: `mintTotp` returns a code, not a secret. The code is
1183
1191
  * embedded in the `wss=` parameter (inside the `at=` param) of the returned
1184
1192
  * URL. Callers MUST NOT log the returned URL to stdout (stderr is OK — it is
@@ -1187,11 +1195,14 @@ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
1187
1195
  * @param relayHttpBaseUrl - Local HTTP base URL of the Chii relay, e.g.
1188
1196
  * `http://127.0.0.1:9100`. No trailing slash.
1189
1197
  * @param targetId - Chii target id (from `GET <relay>/targets`).
1190
- * @param mintTotp - Optional function that returns a fresh 6-digit TOTP code
1191
- * string. Called at most once. When omitted (TOTP disabled) no `at=` param
1192
- * is added.
1198
+ * @param mintTotp - Function that returns a fresh 6-digit TOTP code string.
1199
+ * Called at most once. **Required** when `undefined`, the function returns
1200
+ * `null` (fail-closed: no `at=` param means the relay WS gate rejects the
1201
+ * handshake, so a null result is safer than a URL that always 404s).
1193
1202
  * @param panel - Initial panel. Defaults to `"console"`.
1194
1203
  *
1204
+ * @returns The inspector URL string, or `null` when `mintTotp` is absent.
1205
+ *
1195
1206
  * @example
1196
1207
  * buildChiiInspectorUrl(
1197
1208
  * 'http://127.0.0.1:9100',
@@ -1201,6 +1212,7 @@ function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
1201
1212
  * // → 'http://127.0.0.1:9100/front_end/chii_app.html?ws=127.0.0.1%3A9100%2Fclient%2F<uuid>%3Ftarget%3Dabc123%26at%3D<code>'
1202
1213
  */
1203
1214
  function buildChiiInspectorUrl(relayHttpBaseUrl, targetId, mintTotp, panel = "console") {
1215
+ if (!mintTotp) return null;
1204
1216
  let relayHost;
1205
1217
  let wsParamName;
1206
1218
  try {
@@ -1212,11 +1224,8 @@ function buildChiiInspectorUrl(relayHttpBaseUrl, targetId, mintTotp, panel = "co
1212
1224
  wsParamName = /^https:/i.test(relayHttpBaseUrl) ? "wss" : "ws";
1213
1225
  }
1214
1226
  const clientId = `devtools-opener-${Date.now().toString(36)}`;
1215
- let wsPath = `${relayHost}/client/${clientId}?target=${encodeURIComponent(targetId)}`;
1216
- if (mintTotp) {
1217
- const code = mintTotp();
1218
- wsPath += `&at=${encodeURIComponent(code)}`;
1219
- }
1227
+ const code = mintTotp();
1228
+ const wsPath = `${relayHost}/client/${clientId}?target=${encodeURIComponent(targetId)}&at=${encodeURIComponent(code)}`;
1220
1229
  const params = new URLSearchParams({
1221
1230
  [wsParamName]: wsPath,
1222
1231
  panel
@@ -1325,6 +1334,10 @@ var AutoDevtoolsOpener = class {
1325
1334
  if (!options.targetId) return;
1326
1335
  this._opened = true;
1327
1336
  const inspectorUrl = buildChiiInspectorUrl(options.relayHttpBaseUrl, options.targetId, options.mintTotp);
1337
+ if (inspectorUrl === null) {
1338
+ process.stderr.write("[ait-debug] 기기가 연결됐습니다 — TOTP secret 미설정으로 인스펙터 URL을 생성할 수 없습니다.\n[ait-debug] relay 세션은 AIT_DEBUG_TOTP_SECRET 설정이 필요합니다.\n");
1339
+ return;
1340
+ }
1328
1341
  process.stderr.write(`[ait-debug] 기기가 연결됐습니다 — Chii DevTools를 자동으로 엽니다.
1329
1342
  [ait-debug] DevTools URL: ${inspectorUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 끌 수 있습니다)
1330
1343
  [ait-debug] 주의: URL의 at= 코드는 ~3분 안에서만 유효합니다.
@@ -2096,6 +2109,9 @@ const en = {
2096
2109
  "dashboard.pages.empty": "No attached pages",
2097
2110
  "dashboard.url.copy": "Copy",
2098
2111
  "dashboard.url.copied": "Copied",
2112
+ "dashboard.inspector.section": "Inspector",
2113
+ "dashboard.inspector.open": "Open inspector",
2114
+ "dashboard.inspector.waiting": "Inspector URL pending — appears after a page attaches",
2099
2115
  "attach.title": "AIT Debug Session — QR Scan",
2100
2116
  "attach.deployment": "deployment: {label}",
2101
2117
  "attach.steps.section": "How to scan",
@@ -2139,6 +2155,7 @@ const en = {
2139
2155
  "launcher.diagNo": "no",
2140
2156
  "launcher.letterboxDetected": "Display area is {pt}pt short — likely an iOS standalone letterbox. Removing and re-adding the launcher to the home screen may fix it.",
2141
2157
  "launcher.navbar.defaultTitle": "Mini App",
2158
+ "launcher.navbar.back": "Back",
2142
2159
  "launcher.navbar.menu": "Menu",
2143
2160
  "launcher.navbar.close": "Close",
2144
2161
  "launcher.navbar.menuRescan": "Rescan",
@@ -2344,6 +2361,9 @@ const tables = {
2344
2361
  "dashboard.pages.empty": "attach된 페이지 없음",
2345
2362
  "dashboard.url.copy": "복사",
2346
2363
  "dashboard.url.copied": "복사됨",
2364
+ "dashboard.inspector.section": "인스펙터",
2365
+ "dashboard.inspector.open": "인스펙터 열기",
2366
+ "dashboard.inspector.waiting": "인스펙터 URL 대기 중 (페이지 attach 후 표시됩니다)",
2347
2367
  "attach.title": "AIT 디버그 세션 — QR 스캔",
2348
2368
  "attach.deployment": "deployment: {label}",
2349
2369
  "attach.steps.section": "스캔 절차",
@@ -2387,6 +2407,7 @@ const tables = {
2387
2407
  "launcher.diagNo": "아니요",
2388
2408
  "launcher.letterboxDetected": "표시 영역이 {pt}pt 부족합니다 — iOS standalone letterbox로 보입니다. 런처를 홈 화면에서 제거 후 다시 설치하면 해소될 수 있어요.",
2389
2409
  "launcher.navbar.defaultTitle": "미니앱",
2410
+ "launcher.navbar.back": "뒤로가기",
2390
2411
  "launcher.navbar.menu": "메뉴",
2391
2412
  "launcher.navbar.close": "닫기",
2392
2413
  "launcher.navbar.menuRescan": "다시 스캔",
@@ -2479,6 +2500,14 @@ img.qr {
2479
2500
  }
2480
2501
  .copy-btn:hover { background: #30363d; }
2481
2502
  .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
2503
+ .inspector-link {
2504
+ display: inline-block; margin-top: 0.5rem;
2505
+ padding: 0.45rem 1rem; border-radius: 6px;
2506
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
2507
+ text-decoration: none; text-align: center;
2508
+ }
2509
+ .inspector-link:hover { background: #388bfd; }
2510
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
2482
2511
  ul { margin: 0; padding-left: 1.25rem; }
2483
2512
  li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
2484
2513
  li.empty { opacity: 0.4; list-style: none; padding-left: 0; }
@@ -2488,7 +2517,7 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
2488
2517
  .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
2489
2518
  .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2490
2519
  .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2491
- </style></head><body><h1>AIT 디버그 Dashboard</h1>__LANG_SWITCHER__<p class="updated" id="updated">마지막 갱신: __NOW__</p><section><h2>터널 상태</h2><span class="status __TUNNEL_CLASS__" id="tunnel-status">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id="attach-section">__ATTACH_SECTION__</div></section>__PAGES_SECTION__</body></html>`;
2520
+ </style></head><body><h1>AIT 디버그 Dashboard</h1>__LANG_SWITCHER__<p class="updated" id="updated">마지막 갱신: __NOW__</p><section><h2>터널 상태</h2><span class="status __TUNNEL_CLASS__" id="tunnel-status">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id="attach-section">__ATTACH_SECTION__</div></section><hr/><section id="inspector-section"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section>__PAGES_SECTION__</body></html>`;
2492
2521
  const attachChromeHtmlKoSandbox = `<!DOCTYPE html>
2493
2522
  <html lang="ko"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" as="image" href="__QR_DATA_URL__"/><title>AIT 디버그 세션 — QR 스캔</title><style>
2494
2523
  *, *::before, *::after { box-sizing: border-box; }
@@ -2631,6 +2660,14 @@ img.qr {
2631
2660
  }
2632
2661
  .copy-btn:hover { background: #30363d; }
2633
2662
  .hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
2663
+ .inspector-link {
2664
+ display: inline-block; margin-top: 0.5rem;
2665
+ padding: 0.45rem 1rem; border-radius: 6px;
2666
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
2667
+ text-decoration: none; text-align: center;
2668
+ }
2669
+ .inspector-link:hover { background: #388bfd; }
2670
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
2634
2671
  ul { margin: 0; padding-left: 1.25rem; }
2635
2672
  li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
2636
2673
  li.empty { opacity: 0.4; list-style: none; padding-left: 0; }
@@ -2640,7 +2677,7 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
2640
2677
  .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
2641
2678
  .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2642
2679
  .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2643
- </style></head><body><h1>AIT Debug Dashboard</h1>__LANG_SWITCHER__<p class="updated" id="updated">Last updated: __NOW__</p><section><h2>Tunnel status</h2><span class="status __TUNNEL_CLASS__" id="tunnel-status">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id="attach-section">__ATTACH_SECTION__</div></section>__PAGES_SECTION__</body></html>`;
2680
+ </style></head><body><h1>AIT Debug Dashboard</h1>__LANG_SWITCHER__<p class="updated" id="updated">Last updated: __NOW__</p><section><h2>Tunnel status</h2><span class="status __TUNNEL_CLASS__" id="tunnel-status">__TUNNEL_STATUS__</span></section><hr/><section><h2>Attach QR</h2><div id="attach-section">__ATTACH_SECTION__</div></section><hr/><section id="inspector-section"><h2>Inspector</h2>__INSPECTOR_SECTION__</section>__PAGES_SECTION__</body></html>`;
2644
2681
  const attachChromeHtmlEnSandbox = `<!DOCTYPE html>
2645
2682
  <html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" as="image" href="__QR_DATA_URL__"/><title>AIT Debug Session — QR Scan</title><style>
2646
2683
  *, *::before, *::after { box-sizing: border-box; }
@@ -2817,13 +2854,15 @@ function buildLangSwitcher(path, existingParams, locale, s) {
2817
2854
  *
2818
2855
  * 동적 파트 분류:
2819
2856
  * - "token-fill": 단일 값 교체 (__NOW__, __TUNNEL_CLASS__, __TUNNEL_STATUS__,
2820
- * __ATTACH_SECTION__)
2857
+ * __ATTACH_SECTION__, __INSPECTOR_SECTION__)
2821
2858
  * - "runtime builder": 가변 길이 구조 (__PAGES_SECTION__ — 조건부 렌더 + 가변 rows)
2822
2859
  * - "suffix": inline SSE <script> (빌드 파이프라인 없는 클라이언트 스크립트, locale
2823
2860
  * aware 문자열 포함)
2824
2861
  *
2825
2862
  * SECRET-HANDLING:
2826
2863
  * - attachUrl은 url-box 안에서만 노출 (TOTP at= 코드 캡슐 그대로).
2864
+ * - inspectorUrl은 anchor href 안에서만 노출 (TOTP at= 코드 캡슐 그대로).
2865
+ * relay host + TOTP 코드가 담길 수 있으나 대시보드 HTML은 의도된 transport.
2827
2866
  * - tunnel wssUrl은 "터널 연결됨" 상태 표시에서 UP/DOWN만 노출.
2828
2867
  * wssUrl 값 자체는 dashboard HTML에 넣지 않는다.
2829
2868
  */
@@ -2838,6 +2877,9 @@ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new U
2838
2877
  const copyLabel = escapeHtml(s("dashboard.url.copy"));
2839
2878
  attachSection = `<img class="qr" src="${qrDataUrl}" alt="attach QR" /><div class="url-row"><p class="url-box" id="url-box">${safeAttachUrl}</p><button class="copy-btn" id="copy-btn" type="button" aria-label="${copyLabel}">${copyLabel}</button></div>`;
2840
2879
  } else attachSection = `<p class="hint">${escapeHtml(s("dashboard.attach.hint"))}</p>`;
2880
+ let inspectorSection;
2881
+ if (state.inspectorUrl) inspectorSection = `<a class="inspector-link" id="inspector-link" href="${escapeHtml(state.inspectorUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(s("dashboard.inspector.open"))}</a>`;
2882
+ else inspectorSection = `<span class="inspector-hint" id="inspector-link">${escapeHtml(s("dashboard.inspector.waiting"))}</span>`;
2841
2883
  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) => {
2842
2884
  return `<li><span class="page-id">${escapeHtml(p.id)}</span> <span class="page-url">${escapeHtml(p.url.slice(0, 120))}</span></li>`;
2843
2885
  }).join("\n") : `<li class="empty">${escapeHtml(s("dashboard.pages.empty"))}</li>`}</ul></section>`;
@@ -2848,10 +2890,12 @@ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new U
2848
2890
  attachHint: JSON.stringify(s("dashboard.attach.hint")),
2849
2891
  copyLabel: JSON.stringify(s("dashboard.url.copy")),
2850
2892
  copiedLabel: JSON.stringify(s("dashboard.url.copied")),
2893
+ inspectorOpenLabel: JSON.stringify(s("dashboard.inspector.open")),
2894
+ inspectorWaitingLabel: JSON.stringify(s("dashboard.inspector.waiting")),
2851
2895
  dashboardSurface: true
2852
2896
  };
2853
2897
  const langSwitcher = buildLangSwitcher(path, params, locale, s);
2854
- 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("__PAGES_SECTION__", pagesSection);
2898
+ 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);
2855
2899
  const sseScript = buildSseScript(sseStrings);
2856
2900
  return filled.replace("</body>", `${sseScript}\n</body>`);
2857
2901
  }
@@ -2889,6 +2933,8 @@ function buildSseScript(strings) {
2889
2933
  var ATTACH_HINT = ${strings.attachHint};
2890
2934
  var COPY_LABEL = ${strings.copyLabel};
2891
2935
  var COPIED_LABEL = ${strings.copiedLabel};
2936
+ var INSPECTOR_OPEN_LABEL = ${strings.inspectorOpenLabel};
2937
+ var INSPECTOR_WAITING_LABEL = ${strings.inspectorWaitingLabel};
2892
2938
 
2893
2939
  // ── 클립보드 복사 헬퍼 ────────────────────────────────────────────────
2894
2940
  function copyText(text) {
@@ -3002,6 +3048,17 @@ function buildSseScript(strings) {
3002
3048
  sec.innerHTML = '<p class=\\"hint\\">' + ATTACH_HINT + '</p>';`}
3003
3049
  }
3004
3050
  }
3051
+ // 인스펙터 링크 갱신 — #inspector-link (#503).
3052
+ // SECRET-HANDLING: inspectorUrl을 console.log 등으로 출력하지 않는다.
3053
+ var insp = document.getElementById('inspector-link');
3054
+ if (insp) {
3055
+ if (s.inspectorUrl) {
3056
+ var safeInspUrl = String(s.inspectorUrl).slice(0, 2000).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
3057
+ insp.outerHTML = '<a class=\\"inspector-link\\" id=\\"inspector-link\\" href=\\"' + safeInspUrl + '\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">' + INSPECTOR_OPEN_LABEL + '</a>';
3058
+ } else {
3059
+ insp.outerHTML = '<span class=\\"inspector-hint\\" id=\\"inspector-link\\">' + INSPECTOR_WAITING_LABEL + '</span>';
3060
+ }
3061
+ }
3005
3062
  // 갱신 시각 (dashboard만 #updated 요소 있음)
3006
3063
  var upd = document.getElementById('updated');
3007
3064
  if (upd) upd.textContent = upd.textContent.replace(/[^ ]+$/, new Date().toISOString());
@@ -3046,6 +3103,8 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/a
3046
3103
  attachHint: JSON.stringify(s("dashboard.attach.hint")),
3047
3104
  copyLabel: JSON.stringify(s("dashboard.url.copy")),
3048
3105
  copiedLabel: JSON.stringify(s("dashboard.url.copied")),
3106
+ inspectorOpenLabel: JSON.stringify(s("dashboard.inspector.open")),
3107
+ inspectorWaitingLabel: JSON.stringify(s("dashboard.inspector.waiting")),
3049
3108
  dashboardSurface: false
3050
3109
  });
3051
3110
  return filled.replace("</body>", `${sseScript}\n</body>`);
@@ -3056,8 +3115,9 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/a
3056
3115
  *
3057
3116
  * @param getDashboardState - dashboard 상태를 반환하는 클로저. 주입 시 `GET /` dashboard와
3058
3117
  * `GET /events` SSE 스트림이 활성화된다. 미주입 시 두 라우트는 204/서비스 없음으로 응답.
3118
+ * @param options - 서버 옵션. `sseRefreshIntervalMs`로 idle 탭 TOTP 만료 방지 주기를 조정.
3059
3119
  */
3060
- async function startQrHttpServer(getDashboardState) {
3120
+ async function startQrHttpServer(getDashboardState, options) {
3061
3121
  const { default: QRCode } = await import("qrcode");
3062
3122
  /** SSE 활성 연결 목록 — `notifyStateChange()` 시 전체 push. */
3063
3123
  const sseClients = [];
@@ -3069,7 +3129,8 @@ async function startQrHttpServer(getDashboardState) {
3069
3129
  wssUrl: state.tunnel.wssUrl
3070
3130
  },
3071
3131
  pages: state.pages,
3072
- attachUrl: state.attachUrl
3132
+ attachUrl: state.attachUrl,
3133
+ inspectorUrl: state.inspectorUrl ?? null
3073
3134
  });
3074
3135
  res.write(`data: ${payload}\n\n`);
3075
3136
  }
@@ -3189,19 +3250,28 @@ async function startQrHttpServer(getDashboardState) {
3189
3250
  const address = server.address();
3190
3251
  if (!address || typeof address === "string") throw new Error("qr-http-server: server.address()가 예상하지 못한 형태입니다.");
3191
3252
  const port = address.port;
3253
+ /** idle 탭 TOTP 만료 방지용 주기 SSE 갱신 interval. */
3254
+ function notifyStateChangeInternal() {
3255
+ if (!getDashboardState) return;
3256
+ const state = getDashboardState();
3257
+ for (const client of sseClients) try {
3258
+ pushStateToClient(client, state);
3259
+ } catch {}
3260
+ }
3261
+ const refreshIntervalMs = options?.sseRefreshIntervalMs ?? 9e4;
3262
+ const refreshHandle = setInterval(() => {
3263
+ if (sseClients.length > 0 && getDashboardState) notifyStateChangeInternal();
3264
+ }, refreshIntervalMs).unref();
3192
3265
  return {
3193
3266
  port,
3194
3267
  buildAttachPageUrl(attachUrl) {
3195
3268
  return `http://127.0.0.1:${port}/attach?u=${encodeURIComponent(attachUrl)}`;
3196
3269
  },
3197
3270
  notifyStateChange() {
3198
- if (!getDashboardState) return;
3199
- const state = getDashboardState();
3200
- for (const client of sseClients) try {
3201
- pushStateToClient(client, state);
3202
- } catch {}
3271
+ notifyStateChangeInternal();
3203
3272
  },
3204
3273
  close() {
3274
+ clearInterval(refreshHandle);
3205
3275
  return new Promise((resolve, reject) => {
3206
3276
  server.close((err) => err ? reject(err) : resolve());
3207
3277
  });
@@ -4590,7 +4660,7 @@ async function readMcpSdkVersion() {
4590
4660
  * some test environments that skip the build step).
4591
4661
  */
4592
4662
  function readDevtoolsVersion() {
4593
- return "0.1.73";
4663
+ return "0.1.75";
4594
4664
  }
4595
4665
  /**
4596
4666
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -5094,7 +5164,7 @@ function createDebugServer(deps) {
5094
5164
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
5095
5165
  const server = new Server({
5096
5166
  name: "ait-debug",
5097
- version: "0.1.73"
5167
+ version: "0.1.75"
5098
5168
  }, { capabilities: { tools: { listChanged: true } } });
5099
5169
  server.setRequestHandler(ListToolsRequestSchema, () => {
5100
5170
  const conn = router.active;
@@ -5693,37 +5763,52 @@ function errorResult(err, name, isLocal = false) {
5693
5763
  return classifyToolError(err, name, isLocal);
5694
5764
  }
5695
5765
  /**
5696
- * Starts a polling watcher that detects the first 0→N target transition on
5766
+ * Starts a polling watcher that detects target-set changes on
5697
5767
  * `connection.listTargets()` and sends a `notifications/tools/list_changed`
5698
5768
  * notification on the given server.
5699
5769
  *
5700
5770
  * The watcher polls every `intervalMs` (default 1 000 ms). It fires
5701
- * `server.sendToolListChanged()` exactly once on the first transition — then
5702
- * clears itself. Shutdown calls `stop()` to clear the interval.
5771
+ * `server.sendToolListChanged()` + `onAttach()` whenever the sorted target-id
5772
+ * signature changes AND the new target set is non-empty. This covers:
5773
+ * - 0→N first attach
5774
+ * - 1→1 target replacement (same count, different id — e.g. rescan)
5775
+ * - N→M any change where the result is still non-empty
5776
+ *
5777
+ * Full detach (→ empty) updates the stored signature but does NOT fire the
5778
+ * callback — `onAttach` semantics are about a live target being present.
5779
+ *
5780
+ * The interval is **never cleared automatically** — it keeps running until
5781
+ * `stop()` is called during shutdown. This ensures that a target replacement
5782
+ * after the first attach is always detected.
5703
5783
  *
5704
- * `onFirstAttach` is called once on the 0→N transition (or immediately when
5705
- * already attached). Use this to trigger side-effects such as auto-opening
5706
- * Chrome DevTools (issue #282). The callback is optional; omitting it preserves
5707
- * the previous behaviour exactly.
5784
+ * `onAttach` is called on every non-empty signature change (or immediately when
5785
+ * already attached). Use this to trigger side-effects such as pushing a fresh
5786
+ * SSE state to open dashboard tabs (issue #509). The callback is optional;
5787
+ * omitting it preserves the previous behaviour exactly.
5708
5788
  *
5709
5789
  * SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
5710
5790
  * Only an attach-detected stderr line is emitted (no target details).
5711
5791
  *
5712
5792
  * @returns `stop` — call this during shutdown to clear the interval.
5713
5793
  */
5714
- function startAttachWatcher(connection, server, intervalMs = 1e3, onFirstAttach) {
5715
- let wasAttached = connection.listTargets().length > 0;
5716
- if (wasAttached) {
5794
+ function startAttachWatcher(connection, server, intervalMs = 1e3, onAttach) {
5795
+ /** Sorted, comma-joined target-id string — '' means no targets attached. */
5796
+ function signature() {
5797
+ return connection.listTargets().map((t) => t.id).sort().join(",");
5798
+ }
5799
+ let lastSignature = signature();
5800
+ if (lastSignature !== "") {
5717
5801
  server.sendToolListChanged();
5718
- onFirstAttach?.();
5802
+ onAttach?.();
5719
5803
  }
5720
5804
  const handle = setInterval(() => {
5721
- const isAttached = connection.listTargets().length > 0;
5722
- if (!wasAttached && isAttached) {
5723
- wasAttached = true;
5724
- server.sendToolListChanged();
5725
- onFirstAttach?.();
5726
- clearInterval(handle);
5805
+ const current = signature();
5806
+ if (current !== lastSignature) {
5807
+ lastSignature = current;
5808
+ if (current !== "") {
5809
+ server.sendToolListChanged();
5810
+ onAttach?.();
5811
+ }
5727
5812
  }
5728
5813
  }, intervalMs);
5729
5814
  return { stop() {
@@ -6020,6 +6105,15 @@ var DualConnectionRouter = class {
6020
6105
  get activeRelayOrigin() {
6021
6106
  return this.activeFamily?.relayOrigin;
6022
6107
  }
6108
+ /**
6109
+ * Local HTTP base URL of the Chii relay for the currently-active family (#503).
6110
+ * Used by `getDashboardState` to build the inspector URL via `buildChiiInspectorUrl`.
6111
+ * Returns `undefined` when no relay family is active (local/mock mode).
6112
+ * SECRET-HANDLING: not logged — callers must not write this to stdout/logs.
6113
+ */
6114
+ get activeRelayHttpUrl() {
6115
+ return this.activeFamily?.relayHttpUrl;
6116
+ }
6023
6117
  /** Every booted family (for unified shutdown). All families are lazy (#396). */
6024
6118
  bootedFamilies() {
6025
6119
  return [...this.lazyFamilies.values()];
@@ -6147,18 +6241,25 @@ async function runDebugServer(options = {}) {
6147
6241
  return router.active;
6148
6242
  });
6149
6243
  let lastAttachParts = null;
6150
- const getDashboardState = () => ({
6151
- tunnel: {
6152
- up: router.relayTunnelStatus().up,
6153
- wssUrl: router.relayTunnelStatus().wssUrl
6154
- },
6155
- pages: router.active.listTargets().map((t) => ({
6156
- id: t.id,
6157
- url: t.url
6158
- })),
6159
- attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
6160
- mode: deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin)
6161
- });
6244
+ const getDashboardState = () => {
6245
+ const targets = router.active.listTargets();
6246
+ const relayHttpUrl = router.activeRelayHttpUrl;
6247
+ const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
6248
+ const inspectorUrl = relayHttpUrl && targets.length > 0 ? buildChiiInspectorUrl(relayHttpUrl, targets[0].id, totpSecret ? () => generateTotp(totpSecret, Date.now()) : void 0) : null;
6249
+ return {
6250
+ tunnel: {
6251
+ up: router.relayTunnelStatus().up,
6252
+ wssUrl: router.relayTunnelStatus().wssUrl
6253
+ },
6254
+ pages: targets.map((t) => ({
6255
+ id: t.id,
6256
+ url: t.url
6257
+ })),
6258
+ attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
6259
+ inspectorUrl,
6260
+ mode: deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin)
6261
+ };
6262
+ };
6162
6263
  let qrServer;
6163
6264
  try {
6164
6265
  qrServer = await startQrHttpServer(getDashboardState);
@@ -6313,17 +6414,24 @@ async function runLocalDebugServer(options = {}) {
6313
6414
  return router.active;
6314
6415
  });
6315
6416
  let lastAttachParts = null;
6316
- const getDashboardState = () => ({
6317
- tunnel: {
6318
- up: router.relayTunnelStatus().up,
6319
- wssUrl: router.relayTunnelStatus().wssUrl
6320
- },
6321
- pages: router.active.listTargets().map((t) => ({
6322
- id: t.id,
6323
- url: t.url
6324
- })),
6325
- attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null
6326
- });
6417
+ const getDashboardState = () => {
6418
+ const targets = router.active.listTargets();
6419
+ const relayHttpUrl = router.activeRelayHttpUrl;
6420
+ const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
6421
+ const inspectorUrl = relayHttpUrl && targets.length > 0 ? buildChiiInspectorUrl(relayHttpUrl, targets[0].id, totpSecret ? () => generateTotp(totpSecret, Date.now()) : void 0) : null;
6422
+ return {
6423
+ tunnel: {
6424
+ up: router.relayTunnelStatus().up,
6425
+ wssUrl: router.relayTunnelStatus().wssUrl
6426
+ },
6427
+ pages: targets.map((t) => ({
6428
+ id: t.id,
6429
+ url: t.url
6430
+ })),
6431
+ attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
6432
+ inspectorUrl
6433
+ };
6434
+ };
6327
6435
  let qrServer;
6328
6436
  try {
6329
6437
  qrServer = await startQrHttpServer(getDashboardState);
@@ -6462,17 +6570,24 @@ async function runMobileDebugServer(options = {}) {
6462
6570
  return router.active;
6463
6571
  });
6464
6572
  let lastAttachParts = null;
6465
- const getDashboardState = () => ({
6466
- tunnel: {
6467
- up: router.relayTunnelStatus().up,
6468
- wssUrl: router.relayTunnelStatus().wssUrl
6469
- },
6470
- pages: router.active.listTargets().map((t) => ({
6471
- id: t.id,
6472
- url: t.url
6473
- })),
6474
- attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null
6475
- });
6573
+ const getDashboardState = () => {
6574
+ const targets = router.active.listTargets();
6575
+ const relayHttpUrl = router.activeRelayHttpUrl;
6576
+ const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
6577
+ const inspectorUrl = relayHttpUrl && targets.length > 0 ? buildChiiInspectorUrl(relayHttpUrl, targets[0].id, totpSecret ? () => generateTotp(totpSecret, Date.now()) : void 0) : null;
6578
+ return {
6579
+ tunnel: {
6580
+ up: router.relayTunnelStatus().up,
6581
+ wssUrl: router.relayTunnelStatus().wssUrl
6582
+ },
6583
+ pages: targets.map((t) => ({
6584
+ id: t.id,
6585
+ url: t.url
6586
+ })),
6587
+ attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
6588
+ inspectorUrl
6589
+ };
6590
+ };
6476
6591
  let qrServer;
6477
6592
  try {
6478
6593
  qrServer = await startQrHttpServer(getDashboardState);
@@ -6990,7 +7105,7 @@ function createDevServer(deps = {}) {
6990
7105
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
6991
7106
  const server = new Server({
6992
7107
  name: "ait-devtools",
6993
- version: "0.1.73"
7108
+ version: "0.1.75"
6994
7109
  }, { capabilities: { tools: {} } });
6995
7110
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
6996
7111
  server.setRequestHandler(CallToolRequestSchema, async (request) => {