@ait-co/devtools 0.1.81 → 0.1.84

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 (73) hide show
  1. package/dist/{chii-relay-DSVG4Ui1.js → chii-relay-BASitNMw.js} +1 -1
  2. package/dist/{chii-relay-DSVG4Ui1.js.map → chii-relay-BASitNMw.js.map} +1 -1
  3. package/dist/{chii-relay-BcnVJBqm.cjs → chii-relay-BzUf0LH3.cjs} +1 -1
  4. package/dist/{chii-relay-BcnVJBqm.cjs.map → chii-relay-BzUf0LH3.cjs.map} +1 -1
  5. package/dist/{deeplink-B-94XmWA.js → deeplink-BpO9qc-D.js} +5 -3
  6. package/dist/{deeplink-BLU2_hg6.cjs.map → deeplink-BpO9qc-D.js.map} +1 -1
  7. package/dist/{deeplink-CYqDwVYs.js → deeplink-D1HXJ2YG.js} +5 -3
  8. package/dist/{deeplink-CU6opogq.cjs.map → deeplink-D1HXJ2YG.js.map} +1 -1
  9. package/dist/{deeplink-BLU2_hg6.cjs → deeplink-DCScMYcp.cjs} +5 -3
  10. package/dist/deeplink-DCScMYcp.cjs.map +1 -0
  11. package/dist/{deeplink-CU6opogq.cjs → deeplink-DDOe0FQl.cjs} +5 -3
  12. package/dist/deeplink-DDOe0FQl.cjs.map +1 -0
  13. package/dist/{devtools-opener-Bp671YXu.cjs → devtools-opener-BDY0w3_0.cjs} +11 -5
  14. package/dist/devtools-opener-BDY0w3_0.cjs.map +1 -0
  15. package/dist/{devtools-opener-D84kZFtR.js → devtools-opener-BTl5A6Cd.js} +11 -5
  16. package/dist/devtools-opener-BTl5A6Cd.js.map +1 -0
  17. package/dist/{devtools-opener-BbUXBzgA.js → devtools-opener-XpwL3fZ9.js} +22 -6
  18. package/dist/devtools-opener-XpwL3fZ9.js.map +1 -0
  19. package/dist/{devtools-opener-h6A-UjzC.cjs → devtools-opener-mDgeg_MX.cjs} +11 -5
  20. package/dist/devtools-opener-mDgeg_MX.cjs.map +1 -0
  21. package/dist/machine-state-Chg_6SPq.js +188 -0
  22. package/dist/machine-state-Chg_6SPq.js.map +1 -0
  23. package/dist/machine-state-DOUweFsJ.cjs +216 -0
  24. package/dist/machine-state-DOUweFsJ.cjs.map +1 -0
  25. package/dist/mcp/cli.js +104 -37
  26. package/dist/mcp/cli.js.map +1 -1
  27. package/dist/mcp/server.js +6 -2
  28. package/dist/mcp/server.js.map +1 -1
  29. package/dist/panel/index.js +109 -8
  30. package/dist/panel/index.js.map +1 -1
  31. package/dist/{qr-http-server-DJ5K3Odk.js → qr-http-server-Buorblrx.js} +73 -23
  32. package/dist/qr-http-server-Buorblrx.js.map +1 -0
  33. package/dist/{qr-http-server-Dkx2-pKF.cjs → qr-http-server-BvAtX9Lc.cjs} +73 -23
  34. package/dist/qr-http-server-BvAtX9Lc.cjs.map +1 -0
  35. package/dist/{qr-http-server-DkOFfZsR.js → qr-http-server-BxjrJr9t.js} +73 -23
  36. package/dist/qr-http-server-BxjrJr9t.js.map +1 -0
  37. package/dist/{qr-http-server-MIUHaiYw.cjs → qr-http-server-CAUyOrCm.cjs} +73 -23
  38. package/dist/qr-http-server-CAUyOrCm.cjs.map +1 -0
  39. package/dist/{relay-secret-store-CsCOfpWt.cjs → relay-secret-store-B5WAozDv.cjs} +2 -2
  40. package/dist/{relay-secret-store-CsCOfpWt.cjs.map → relay-secret-store-B5WAozDv.cjs.map} +1 -1
  41. package/dist/{relay-secret-store-6pPzLkUO.js → relay-secret-store-BvNWdSjV.js} +2 -2
  42. package/dist/{relay-secret-store-6pPzLkUO.js.map → relay-secret-store-BvNWdSjV.js.map} +1 -1
  43. package/dist/{relay-url-store-DH8-VUFc.js → relay-url-store-1CXVqNDL.js} +2 -2
  44. package/dist/{relay-url-store-DH8-VUFc.js.map → relay-url-store-1CXVqNDL.js.map} +1 -1
  45. package/dist/{relay-url-store-BiEK9BN1.cjs → relay-url-store-D2lX9POP.cjs} +2 -2
  46. package/dist/{relay-url-store-BiEK9BN1.cjs.map → relay-url-store-D2lX9POP.cjs.map} +1 -1
  47. package/dist/{totp-DYdP9N3o.js → totp-CauHjkdE.js} +1 -1
  48. package/dist/{totp-DYdP9N3o.js.map → totp-CauHjkdE.js.map} +1 -1
  49. package/dist/{totp-CNw0w89F.cjs → totp-D9fjaVak.cjs} +1 -1
  50. package/dist/{totp-CNw0w89F.cjs.map → totp-D9fjaVak.cjs.map} +1 -1
  51. package/dist/{tunnel-C-AFdAVL.cjs → tunnel-CfT31xho.cjs} +6 -6
  52. package/dist/{tunnel-C-AFdAVL.cjs.map → tunnel-CfT31xho.cjs.map} +1 -1
  53. package/dist/{tunnel-BTlq1mmH.js → tunnel-DPwJBn1u.js} +6 -6
  54. package/dist/{tunnel-BTlq1mmH.js.map → tunnel-DPwJBn1u.js.map} +1 -1
  55. package/dist/unplugin/index.cjs +90 -5
  56. package/dist/unplugin/index.cjs.map +1 -1
  57. package/dist/unplugin/index.d.cts.map +1 -1
  58. package/dist/unplugin/index.d.ts.map +1 -1
  59. package/dist/unplugin/index.js +90 -5
  60. package/dist/unplugin/index.js.map +1 -1
  61. package/dist/unplugin/tunnel.cjs +4 -4
  62. package/dist/unplugin/tunnel.js +4 -4
  63. package/package.json +1 -1
  64. package/dist/deeplink-B-94XmWA.js.map +0 -1
  65. package/dist/deeplink-CYqDwVYs.js.map +0 -1
  66. package/dist/devtools-opener-BbUXBzgA.js.map +0 -1
  67. package/dist/devtools-opener-Bp671YXu.cjs.map +0 -1
  68. package/dist/devtools-opener-D84kZFtR.js.map +0 -1
  69. package/dist/devtools-opener-h6A-UjzC.cjs.map +0 -1
  70. package/dist/qr-http-server-DJ5K3Odk.js.map +0 -1
  71. package/dist/qr-http-server-DkOFfZsR.js.map +0 -1
  72. package/dist/qr-http-server-Dkx2-pKF.cjs.map +0 -1
  73. package/dist/qr-http-server-MIUHaiYw.cjs.map +0 -1
@@ -0,0 +1,216 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ let node_os = require("node:os");
14
+ let node_path = require("node:path");
15
+ //#region src/telemetry/machine-state.ts
16
+ /**
17
+ * Machine-level telemetry consent store — #542
18
+ *
19
+ * Persists consent to `~/.ait-devtools/telemetry.json` (Node.js side only).
20
+ * This eliminates repeated consent prompts when the dev origin rotates
21
+ * (quick-tunnel host changes per session, localhost port varies).
22
+ *
23
+ * Design invariants:
24
+ * - localStorage keys in src/telemetry/state.ts are LOCKED — this module does
25
+ * NOT replace them. It adds a machine-level overlay that takes precedence
26
+ * when a dev server is running.
27
+ * - Tier 0 / Tier 1 consent semantics are unchanged — only the storage
28
+ * location and lifetime change.
29
+ * - No PII. The file stores: consent enum + decided_at ISO timestamp +
30
+ * policy_version + anon_id (pseudonymous UUID).
31
+ * - anon_id is promoted to machine level so the same identity is used across
32
+ * all dev origins (prevents counting the same developer multiple times).
33
+ *
34
+ * Node.js-only: imported exclusively from the unplugin (server-side).
35
+ * Never imported from panel/browser code.
36
+ */
37
+ var machine_state_exports = /* @__PURE__ */ __exportAll({
38
+ ensureMachineConsent: () => ensureMachineConsent,
39
+ machineStateDir: () => machineStateDir,
40
+ machineStateFile: () => machineStateFile,
41
+ promptTtyConsent: () => promptTtyConsent,
42
+ readMachineState: () => readMachineState,
43
+ writeMachineState: () => writeMachineState
44
+ });
45
+ /** Resolved directory: `~/.ait-devtools`. */
46
+ function machineStateDir(homeOverride) {
47
+ return (0, node_path.join)(homeOverride ?? (0, node_os.homedir)(), ".ait-devtools");
48
+ }
49
+ /** Resolved file: `~/.ait-devtools/telemetry.json`. */
50
+ function machineStateFile(homeOverride) {
51
+ return (0, node_path.join)(machineStateDir(homeOverride), "telemetry.json");
52
+ }
53
+ function isoNow() {
54
+ return (/* @__PURE__ */ new Date()).toISOString();
55
+ }
56
+ function newUUID() {
57
+ return crypto.randomUUID();
58
+ }
59
+ /**
60
+ * Returns a minimal MachineStateFsDep using real `node:fs` synchronous APIs.
61
+ * Resolved lazily to avoid pulling node:fs into non-Node environments.
62
+ */
63
+ async function realFs() {
64
+ const { existsSync, mkdirSync, readFileSync, writeFileSync } = await import("node:fs");
65
+ return {
66
+ existsSync,
67
+ mkdirSync,
68
+ readFileSync,
69
+ writeFileSync
70
+ };
71
+ }
72
+ /**
73
+ * Reads the machine-level telemetry state.
74
+ * Returns `null` when the file is absent or cannot be parsed.
75
+ *
76
+ * NODE-ONLY. Never call from browser/panel code.
77
+ */
78
+ async function readMachineState(deps) {
79
+ const fs = deps?.fs ?? await realFs();
80
+ const filePath = machineStateFile(deps?.homeDir);
81
+ if (!fs.existsSync(filePath)) return null;
82
+ let raw;
83
+ try {
84
+ raw = fs.readFileSync(filePath, "utf8");
85
+ } catch {
86
+ return null;
87
+ }
88
+ try {
89
+ const parsed = JSON.parse(raw);
90
+ if (parsed !== null && typeof parsed === "object" && "consent" in parsed && typeof parsed.consent === "string" && [
91
+ "granted",
92
+ "denied",
93
+ "undecided"
94
+ ].includes(parsed.consent)) return parsed;
95
+ return null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Writes (or patches) the machine-level telemetry state file.
102
+ * Creates `~/.ait-devtools/` if it does not yet exist.
103
+ *
104
+ * NODE-ONLY. Never call from browser/panel code.
105
+ */
106
+ async function writeMachineState(patch, deps) {
107
+ const fs = deps?.fs ?? await realFs();
108
+ const dir = machineStateDir(deps?.homeDir);
109
+ const filePath = machineStateFile(deps?.homeDir);
110
+ const now = deps?.now ?? isoNow;
111
+ const uuid = deps?.randomUUID ?? newUUID;
112
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
113
+ let existing = null;
114
+ if (fs.existsSync(filePath)) try {
115
+ const raw = fs.readFileSync(filePath, "utf8");
116
+ existing = JSON.parse(raw);
117
+ } catch {}
118
+ const next = {
119
+ ...existing ?? {
120
+ consent: "undecided",
121
+ decided_at: now(),
122
+ policy_version: "",
123
+ anon_id: null
124
+ },
125
+ ...patch,
126
+ anon_id: existing?.anon_id ?? patch.anon_id ?? uuid(),
127
+ decided_at: now()
128
+ };
129
+ fs.writeFileSync(filePath, JSON.stringify(next, null, 2), { mode: 384 });
130
+ }
131
+ /**
132
+ * Prompts the user in the terminal (stdin/stdout TTY) to accept or deny
133
+ * telemetry consent — called once per machine on the first `pnpm dev` run.
134
+ *
135
+ * Returns the chosen `ConsentState`, or `'undecided'` when:
136
+ * - The TTY is not interactive (CI, headless, piped stdin).
137
+ * - The prompt times out.
138
+ * - An error occurs.
139
+ *
140
+ * NODE-ONLY. Called from the unplugin `configureServer` hook.
141
+ */
142
+ async function promptTtyConsent() {
143
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return "undecided";
144
+ const { createInterface } = await import("node:readline");
145
+ const rl = createInterface({
146
+ input: process.stdin,
147
+ output: process.stdout
148
+ });
149
+ const question = (q) => new Promise((resolve) => rl.question(q, resolve));
150
+ process.stdout.write("\n[@ait-co/devtools] 익명 사용 통계를 보내도 될까요?\n 버전·날짜만 수집하고 PII는 없습니다. 언제든 Environment 탭에서 끌 수 있어요.\n 자세히: https://docs.aitc.dev/privacy\n (y) 네, 보낼게요 (n) 아니요 (무응답 시 보류 — 다음 기동 때 다시 물어요)\n");
151
+ let answer = "";
152
+ try {
153
+ answer = await Promise.race([question(" > "), new Promise((resolve) => setTimeout(() => resolve(""), 3e4))]);
154
+ } catch {} finally {
155
+ rl.close();
156
+ }
157
+ const normalized = answer.trim().toLowerCase();
158
+ if (normalized === "y" || normalized === "yes" || normalized === "네" || normalized === "ㅇ") return "granted";
159
+ if (normalized === "n" || normalized === "no" || normalized === "아니요" || normalized === "ㄴ") return "denied";
160
+ return "undecided";
161
+ }
162
+ /**
163
+ * Ensures the machine-level consent state is resolved.
164
+ *
165
+ * Algorithm:
166
+ * 1. Read the machine file.
167
+ * 2. If already decided (granted/denied) → return as-is (no re-prompt).
168
+ * Exception: granted + stale policy_version → revert to undecided (mirrors
169
+ * browser-side resolveEffectiveConsent).
170
+ * 3. If undecided or absent → TTY-prompt once. Persist the answer (including
171
+ * 'undecided' — the directory is guaranteed to exist for subsequent writes,
172
+ * and decided_at is set for potential future reprompt-window logic).
173
+ *
174
+ * Returns the resolved `MachineTelemetryState`. Callers decide what to do with it.
175
+ *
176
+ * NODE-ONLY.
177
+ */
178
+ async function ensureMachineConsent(policyVersion, deps) {
179
+ const existing = await readMachineState(deps);
180
+ if (existing?.consent === "granted" || existing?.consent === "denied") {
181
+ if (existing.consent === "granted" && existing.policy_version !== policyVersion) {
182
+ await writeMachineState({
183
+ consent: "undecided",
184
+ policy_version: policyVersion
185
+ }, deps);
186
+ return ensureMachineConsent(policyVersion, deps);
187
+ }
188
+ return existing;
189
+ }
190
+ await writeMachineState({
191
+ consent: await (deps?.promptConsent ?? promptTtyConsent)(),
192
+ policy_version: policyVersion
193
+ }, deps);
194
+ return await readMachineState(deps);
195
+ }
196
+ //#endregion
197
+ Object.defineProperty(exports, "ensureMachineConsent", {
198
+ enumerable: true,
199
+ get: function() {
200
+ return ensureMachineConsent;
201
+ }
202
+ });
203
+ Object.defineProperty(exports, "machine_state_exports", {
204
+ enumerable: true,
205
+ get: function() {
206
+ return machine_state_exports;
207
+ }
208
+ });
209
+ Object.defineProperty(exports, "writeMachineState", {
210
+ enumerable: true,
211
+ get: function() {
212
+ return writeMachineState;
213
+ }
214
+ });
215
+
216
+ //# sourceMappingURL=machine-state-DOUweFsJ.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"machine-state-DOUweFsJ.cjs","names":[],"sources":["../src/telemetry/machine-state.ts"],"sourcesContent":["/**\n * Machine-level telemetry consent store — #542\n *\n * Persists consent to `~/.ait-devtools/telemetry.json` (Node.js side only).\n * This eliminates repeated consent prompts when the dev origin rotates\n * (quick-tunnel host changes per session, localhost port varies).\n *\n * Design invariants:\n * - localStorage keys in src/telemetry/state.ts are LOCKED — this module does\n * NOT replace them. It adds a machine-level overlay that takes precedence\n * when a dev server is running.\n * - Tier 0 / Tier 1 consent semantics are unchanged — only the storage\n * location and lifetime change.\n * - No PII. The file stores: consent enum + decided_at ISO timestamp +\n * policy_version + anon_id (pseudonymous UUID).\n * - anon_id is promoted to machine level so the same identity is used across\n * all dev origins (prevents counting the same developer multiple times).\n *\n * Node.js-only: imported exclusively from the unplugin (server-side).\n * Never imported from panel/browser code.\n */\n\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nimport type { ConsentState } from './state.js';\n\n// ---------------------------------------------------------------------------\n// Schema\n// ---------------------------------------------------------------------------\n\nexport interface MachineTelemetryState {\n consent: ConsentState;\n /** ISO 8601 timestamp of the decision (or first-write). */\n decided_at: string;\n /** Policy version string at decision time. */\n policy_version: string;\n /**\n * Machine-level anon_id (UUID v4). Created once and never overwritten.\n * Promoted from per-origin localStorage to here so all dev origins share\n * a single pseudonymous identity.\n */\n anon_id: string | null;\n}\n\n// ---------------------------------------------------------------------------\n// File path helpers\n// ---------------------------------------------------------------------------\n\n/** Resolved directory: `~/.ait-devtools`. */\nexport function machineStateDir(homeOverride?: string): string {\n return join(homeOverride ?? homedir(), '.ait-devtools');\n}\n\n/** Resolved file: `~/.ait-devtools/telemetry.json`. */\nexport function machineStateFile(homeOverride?: string): string {\n return join(machineStateDir(homeOverride), 'telemetry.json');\n}\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface (injectable for tests)\n// ---------------------------------------------------------------------------\n\nexport interface MachineStateFsDep {\n existsSync(path: string): boolean;\n mkdirSync(path: string, options?: { recursive?: boolean }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n writeFileSync(path: string, data: string, options?: { mode?: number }): void;\n}\n\nexport interface MachineStateDeps {\n fs?: MachineStateFsDep;\n homeDir?: string;\n now?: () => string;\n randomUUID?: () => string;\n /**\n * Override for the TTY consent prompt. When provided, `ensureMachineConsent`\n * calls this instead of the real `promptTtyConsent` — useful in tests where\n * `process.stdin.isTTY` cannot be spied on.\n */\n promptConsent?: () => Promise<ConsentState>;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction isoNow(): string {\n return new Date().toISOString();\n}\n\nfunction newUUID(): string {\n return crypto.randomUUID();\n}\n\n/**\n * Returns a minimal MachineStateFsDep using real `node:fs` synchronous APIs.\n * Resolved lazily to avoid pulling node:fs into non-Node environments.\n */\nasync function realFs(): Promise<MachineStateFsDep> {\n const { existsSync, mkdirSync, readFileSync, writeFileSync } = await import('node:fs');\n return { existsSync, mkdirSync, readFileSync, writeFileSync };\n}\n\n// ---------------------------------------------------------------------------\n// Read\n// ---------------------------------------------------------------------------\n\n/**\n * Reads the machine-level telemetry state.\n * Returns `null` when the file is absent or cannot be parsed.\n *\n * NODE-ONLY. Never call from browser/panel code.\n */\nexport async function readMachineState(\n deps?: MachineStateDeps,\n): Promise<MachineTelemetryState | null> {\n const fs = deps?.fs ?? (await realFs());\n const filePath = machineStateFile(deps?.homeDir);\n\n if (!fs.existsSync(filePath)) return null;\n\n let raw: string;\n try {\n raw = fs.readFileSync(filePath, 'utf8');\n } catch {\n return null;\n }\n\n try {\n const parsed = JSON.parse(raw) as unknown;\n if (\n parsed !== null &&\n typeof parsed === 'object' &&\n 'consent' in parsed &&\n typeof (parsed as { consent: unknown }).consent === 'string' &&\n ['granted', 'denied', 'undecided'].includes((parsed as { consent: string }).consent)\n ) {\n return parsed as MachineTelemetryState;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Write\n// ---------------------------------------------------------------------------\n\n/**\n * Writes (or patches) the machine-level telemetry state file.\n * Creates `~/.ait-devtools/` if it does not yet exist.\n *\n * NODE-ONLY. Never call from browser/panel code.\n */\nexport async function writeMachineState(\n patch: Partial<MachineTelemetryState>,\n deps?: MachineStateDeps,\n): Promise<void> {\n const fs = deps?.fs ?? (await realFs());\n const dir = machineStateDir(deps?.homeDir);\n const filePath = machineStateFile(deps?.homeDir);\n const now = deps?.now ?? isoNow;\n const uuid = deps?.randomUUID ?? newUUID;\n\n // Ensure directory exists.\n if (!fs.existsSync(dir)) {\n fs.mkdirSync(dir, { recursive: true });\n }\n\n // Merge with existing state (if any).\n let existing: MachineTelemetryState | null = null;\n if (fs.existsSync(filePath)) {\n try {\n const raw = fs.readFileSync(filePath, 'utf8');\n existing = JSON.parse(raw) as MachineTelemetryState;\n } catch {\n // Corrupt file — overwrite.\n }\n }\n\n const base: MachineTelemetryState = existing ?? {\n consent: 'undecided',\n decided_at: now(),\n policy_version: '',\n anon_id: null,\n };\n\n const next: MachineTelemetryState = {\n ...base,\n ...patch,\n // Never overwrite an existing anon_id — generate one if creating fresh.\n anon_id: existing?.anon_id ?? patch.anon_id ?? uuid(),\n decided_at: now(),\n };\n\n fs.writeFileSync(filePath, JSON.stringify(next, null, 2), { mode: 0o600 });\n}\n\n// ---------------------------------------------------------------------------\n// TTY consent prompt (dev server only)\n// ---------------------------------------------------------------------------\n\n/**\n * Prompts the user in the terminal (stdin/stdout TTY) to accept or deny\n * telemetry consent — called once per machine on the first `pnpm dev` run.\n *\n * Returns the chosen `ConsentState`, or `'undecided'` when:\n * - The TTY is not interactive (CI, headless, piped stdin).\n * - The prompt times out.\n * - An error occurs.\n *\n * NODE-ONLY. Called from the unplugin `configureServer` hook.\n */\nexport async function promptTtyConsent(): Promise<ConsentState> {\n // Guard: require an interactive terminal on both ends.\n if (!process.stdin.isTTY || !process.stdout.isTTY) {\n return 'undecided';\n }\n\n const { createInterface } = await import('node:readline');\n const rl = createInterface({ input: process.stdin, output: process.stdout });\n\n const question = (q: string): Promise<string> =>\n new Promise((resolve) => rl.question(q, resolve));\n\n process.stdout.write(\n '\\n[@ait-co/devtools] 익명 사용 통계를 보내도 될까요?\\n' +\n ' 버전·날짜만 수집하고 PII는 없습니다. 언제든 Environment 탭에서 끌 수 있어요.\\n' +\n ' 자세히: https://docs.aitc.dev/privacy\\n' +\n ' (y) 네, 보낼게요 (n) 아니요 (무응답 시 보류 — 다음 기동 때 다시 물어요)\\n',\n );\n\n let answer = '';\n try {\n // 30-second timeout — if the user ignores the prompt, default to 'undecided'.\n answer = await Promise.race([\n question(' > '),\n new Promise<string>((resolve) => setTimeout(() => resolve(''), 30_000)),\n ]);\n } catch {\n // readline error — stay undecided.\n } finally {\n rl.close();\n }\n\n const normalized = answer.trim().toLowerCase();\n if (normalized === 'y' || normalized === 'yes' || normalized === '네' || normalized === 'ㅇ') {\n return 'granted';\n }\n if (normalized === 'n' || normalized === 'no' || normalized === '아니요' || normalized === 'ㄴ') {\n return 'denied';\n }\n // Empty / timeout / unrecognised → undecided (will be asked again next session).\n return 'undecided';\n}\n\n// ---------------------------------------------------------------------------\n// Bootstrap helper — called from configureServer once dev server is ready\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures the machine-level consent state is resolved.\n *\n * Algorithm:\n * 1. Read the machine file.\n * 2. If already decided (granted/denied) → return as-is (no re-prompt).\n * Exception: granted + stale policy_version → revert to undecided (mirrors\n * browser-side resolveEffectiveConsent).\n * 3. If undecided or absent → TTY-prompt once. Persist the answer (including\n * 'undecided' — the directory is guaranteed to exist for subsequent writes,\n * and decided_at is set for potential future reprompt-window logic).\n *\n * Returns the resolved `MachineTelemetryState`. Callers decide what to do with it.\n *\n * NODE-ONLY.\n */\nexport async function ensureMachineConsent(\n policyVersion: string,\n deps?: MachineStateDeps,\n): Promise<MachineTelemetryState> {\n const existing = await readMachineState(deps);\n\n if (existing?.consent === 'granted' || existing?.consent === 'denied') {\n // Policy-version bump: re-prompt only when previously granted (same logic as\n // browser-side resolveEffectiveConsent — denied stays denied on version change).\n if (existing.consent === 'granted' && existing.policy_version !== policyVersion) {\n await writeMachineState({ consent: 'undecided', policy_version: policyVersion }, deps);\n // Recurse: will now prompt the user.\n return ensureMachineConsent(policyVersion, deps);\n }\n return existing;\n }\n\n // Undecided or absent → prompt (use injected override in tests).\n const prompt = deps?.promptConsent ?? promptTtyConsent;\n const answer = await prompt();\n await writeMachineState({ consent: answer, policy_version: policyVersion }, deps);\n\n // Re-read to get the full written state (includes newly generated anon_id).\n return (await readMachineState(deps)) as MachineTelemetryState;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkDA,SAAgB,gBAAgB,cAA+B;AAC7D,SAAA,GAAA,UAAA,MAAY,iBAAA,GAAA,QAAA,UAAyB,EAAE,gBAAgB;;;AAIzD,SAAgB,iBAAiB,cAA+B;AAC9D,SAAA,GAAA,UAAA,MAAY,gBAAgB,aAAa,EAAE,iBAAiB;;AA+B9D,SAAS,SAAiB;AACxB,yBAAO,IAAI,MAAM,EAAC,aAAa;;AAGjC,SAAS,UAAkB;AACzB,QAAO,OAAO,YAAY;;;;;;AAO5B,eAAe,SAAqC;CAClD,MAAM,EAAE,YAAY,WAAW,cAAc,kBAAkB,MAAM,OAAO;AAC5E,QAAO;EAAE;EAAY;EAAW;EAAc;EAAe;;;;;;;;AAa/D,eAAsB,iBACpB,MACuC;CACvC,MAAM,KAAK,MAAM,MAAO,MAAM,QAAQ;CACtC,MAAM,WAAW,iBAAiB,MAAM,QAAQ;AAEhD,KAAI,CAAC,GAAG,WAAW,SAAS,CAAE,QAAO;CAErC,IAAI;AACJ,KAAI;AACF,QAAM,GAAG,aAAa,UAAU,OAAO;SACjC;AACN,SAAO;;AAGT,KAAI;EACF,MAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,MACE,WAAW,QACX,OAAO,WAAW,YAClB,aAAa,UACb,OAAQ,OAAgC,YAAY,YACpD;GAAC;GAAW;GAAU;GAAY,CAAC,SAAU,OAA+B,QAAQ,CAEpF,QAAO;AAET,SAAO;SACD;AACN,SAAO;;;;;;;;;AAcX,eAAsB,kBACpB,OACA,MACe;CACf,MAAM,KAAK,MAAM,MAAO,MAAM,QAAQ;CACtC,MAAM,MAAM,gBAAgB,MAAM,QAAQ;CAC1C,MAAM,WAAW,iBAAiB,MAAM,QAAQ;CAChD,MAAM,MAAM,MAAM,OAAO;CACzB,MAAM,OAAO,MAAM,cAAc;AAGjC,KAAI,CAAC,GAAG,WAAW,IAAI,CACrB,IAAG,UAAU,KAAK,EAAE,WAAW,MAAM,CAAC;CAIxC,IAAI,WAAyC;AAC7C,KAAI,GAAG,WAAW,SAAS,CACzB,KAAI;EACF,MAAM,MAAM,GAAG,aAAa,UAAU,OAAO;AAC7C,aAAW,KAAK,MAAM,IAAI;SACpB;CAYV,MAAM,OAA8B;EAClC,GARkC,YAAY;GAC9C,SAAS;GACT,YAAY,KAAK;GACjB,gBAAgB;GAChB,SAAS;GACV;EAIC,GAAG;EAEH,SAAS,UAAU,WAAW,MAAM,WAAW,MAAM;EACrD,YAAY,KAAK;EAClB;AAED,IAAG,cAAc,UAAU,KAAK,UAAU,MAAM,MAAM,EAAE,EAAE,EAAE,MAAM,KAAO,CAAC;;;;;;;;;;;;;AAkB5E,eAAsB,mBAA0C;AAE9D,KAAI,CAAC,QAAQ,MAAM,SAAS,CAAC,QAAQ,OAAO,MAC1C,QAAO;CAGT,MAAM,EAAE,oBAAoB,MAAM,OAAO;CACzC,MAAM,KAAK,gBAAgB;EAAE,OAAO,QAAQ;EAAO,QAAQ,QAAQ;EAAQ,CAAC;CAE5E,MAAM,YAAY,MAChB,IAAI,SAAS,YAAY,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEnD,SAAQ,OAAO,MACb,8LAID;CAED,IAAI,SAAS;AACb,KAAI;AAEF,WAAS,MAAM,QAAQ,KAAK,CAC1B,SAAS,OAAO,EAChB,IAAI,SAAiB,YAAY,iBAAiB,QAAQ,GAAG,EAAE,IAAO,CAAC,CACxE,CAAC;SACI,WAEE;AACR,KAAG,OAAO;;CAGZ,MAAM,aAAa,OAAO,MAAM,CAAC,aAAa;AAC9C,KAAI,eAAe,OAAO,eAAe,SAAS,eAAe,OAAO,eAAe,IACrF,QAAO;AAET,KAAI,eAAe,OAAO,eAAe,QAAQ,eAAe,SAAS,eAAe,IACtF,QAAO;AAGT,QAAO;;;;;;;;;;;;;;;;;;AAuBT,eAAsB,qBACpB,eACA,MACgC;CAChC,MAAM,WAAW,MAAM,iBAAiB,KAAK;AAE7C,KAAI,UAAU,YAAY,aAAa,UAAU,YAAY,UAAU;AAGrE,MAAI,SAAS,YAAY,aAAa,SAAS,mBAAmB,eAAe;AAC/E,SAAM,kBAAkB;IAAE,SAAS;IAAa,gBAAgB;IAAe,EAAE,KAAK;AAEtF,UAAO,qBAAqB,eAAe,KAAK;;AAElD,SAAO;;AAMT,OAAM,kBAAkB;EAAE,SADX,OADA,MAAM,iBAAiB,mBACT;EACc,gBAAgB;EAAe,EAAE,KAAK;AAGjF,QAAQ,MAAM,iBAAiB,KAAK"}
package/dist/mcp/cli.js CHANGED
@@ -1031,9 +1031,10 @@ const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
1031
1031
  * @param totpCode - Optional current TOTP code (6 digits). When provided, it
1032
1032
  * is appended as `at=<totpCode>`. Must be computed at call time — it rotates
1033
1033
  * every 30 s. Omit when TOTP is disabled.
1034
- * @param opts - Optional app identity hints: `name` and `icon` (#498).
1034
+ * @param opts - Optional app identity hints: `name`, `icon`, and `selfdebug`
1035
+ * (#498, #543).
1035
1036
  * @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>
1036
- * [&at=<code>][&name=<enc>][&icon=<enc>]` params.
1037
+ * [&at=<code>][&name=<enc>][&icon=<enc>][&selfdebug=1]` params.
1037
1038
  */
1038
1039
  function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
1039
1040
  let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}&debug=1&relay=${encodeURIComponent(wssUrl)}`;
@@ -1048,6 +1049,7 @@ function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode, opts) {
1048
1049
  }
1049
1050
  if (iconParsed?.protocol === "https:") url += `&icon=${encodeURIComponent(opts.icon)}`;
1050
1051
  }
1052
+ if (opts?.selfdebug === true) url += "&selfdebug=1";
1051
1053
  return url;
1052
1054
  }
1053
1055
  /**
@@ -1233,12 +1235,18 @@ function buildChiiInspectorUrl(relayHttpBaseUrl, targetId, mintTotp, panel = "co
1233
1235
  return `${relayHttpBaseUrl.replace(/\/$/, "")}/front_end/chii_app.html?${params.toString()}`;
1234
1236
  }
1235
1237
  /**
1236
- * Returns `true` when auto-open is **disabled** via the `AIT_AUTO_DEVTOOLS`
1237
- * env var. Only the explicit `"0"` value disables it; anything else (including
1238
- * absent) leaves auto-open enabled.
1238
+ * Returns `true` when auto-open is **disabled**.
1239
+ *
1240
+ * Default (env var absent or any value other than `"1"`) is **disabled**
1241
+ * the developer uses the "디버그 툴 열기" button on the /attach or dashboard
1242
+ * page instead. Set `AIT_AUTO_DEVTOOLS=1` to restore the old automatic
1243
+ * browser-open behaviour on device attach.
1244
+ *
1245
+ * `AIT_AUTO_DEVTOOLS=0` retains its explicit opt-out meaning for backward
1246
+ * compatibility (same effect as absent).
1239
1247
  */
1240
1248
  function isAutoDevtoolsDisabled() {
1241
- return process.env.AIT_AUTO_DEVTOOLS === "0";
1249
+ return process.env.AIT_AUTO_DEVTOOLS !== "1";
1242
1250
  }
1243
1251
  /**
1244
1252
  * Opens the given URL in the OS default browser using a platform-appropriate
@@ -1341,8 +1349,8 @@ var AutoDevtoolsOpener = class {
1341
1349
  if (options.inspectorStableUrl) {
1342
1350
  this._openedTargets.add(targetId);
1343
1351
  const stableUrl = options.inspectorStableUrl;
1344
- process.stderr.write(`[ait-debug] 기기가 연결됐습니다 — Chii DevTools를 자동으로 엽니다.
1345
- [ait-debug] 인스펙터 URL: ${stableUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 있습니다)
1352
+ process.stderr.write(`[ait-debug] 기기가 연결됐습니다.
1353
+ [ait-debug] QR 페이지 또는 대시보드(${stableUrl.replace("/inspector", "")})의 "디버그 툴 열기" 버튼을 눌러 DevTools를 여세요.\n[ait-debug] (AIT_AUTO_DEVTOOLS=1 설정하면 연결 자동으로 열립니다)
1346
1354
  `);
1347
1355
  if (!openUrlInBrowser(stableUrl)) process.stderr.write(`[ait-debug] 브라우저 자동 열기 실패 — ${stableUrl} 을 브라우저에서 직접 여세요.\n`);
1348
1356
  return;
@@ -1354,8 +1362,8 @@ var AutoDevtoolsOpener = class {
1354
1362
  process.stderr.write("[ait-debug] 기기가 연결됐습니다 — TOTP secret 미설정으로 인스펙터 URL을 생성할 수 없습니다.\n[ait-debug] relay 세션은 AIT_DEBUG_TOTP_SECRET 설정이 필요합니다.\n");
1355
1363
  return;
1356
1364
  }
1357
- process.stderr.write(`[ait-debug] 기기가 연결됐습니다 — Chii DevTools를 자동으로 엽니다.
1358
- [ait-debug] DevTools URL: ${inspectorUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=0 으로 자동 열기를 있습니다)
1365
+ process.stderr.write(`[ait-debug] 기기가 연결됐습니다.
1366
+ [ait-debug] DevTools URL: ${inspectorUrl}\n[ait-debug] (AIT_AUTO_DEVTOOLS=1 설정하면 연결 자동으로 열립니다)
1359
1367
  [ait-debug] 주의: URL의 at= 코드는 ~3분 안에서만 유효합니다.
1360
1368
  `);
1361
1369
  if (!openUrlInBrowser(inspectorUrl)) process.stderr.write("[ait-debug] 브라우저 자동 열기 실패 — 위 URL을 브라우저에서 직접 여세요.\n");
@@ -2134,8 +2142,8 @@ const en = {
2134
2142
  "dashboard.url.copy": "Copy",
2135
2143
  "dashboard.url.copied": "Copied",
2136
2144
  "dashboard.inspector.section": "Inspector",
2137
- "dashboard.inspector.open": "Open inspector",
2138
- "dashboard.inspector.waiting": "Inspector URL pending appears after a page attaches",
2145
+ "dashboard.inspector.open": "Open DevTools",
2146
+ "dashboard.inspector.waiting": "Attach a page to enable the \"Open DevTools\" button",
2139
2147
  "inspector.error.noTarget": "No page attached. Attach a device and try again.",
2140
2148
  "inspector.error.relayDown": "Relay is not active. Start a relay session first.",
2141
2149
  "attach.title": "AIT Debug Session — QR Scan",
@@ -2388,8 +2396,8 @@ const tables = {
2388
2396
  "dashboard.url.copy": "복사",
2389
2397
  "dashboard.url.copied": "복사됨",
2390
2398
  "dashboard.inspector.section": "인스펙터",
2391
- "dashboard.inspector.open": "인스펙터 열기",
2392
- "dashboard.inspector.waiting": "인스펙터 URL 대기 (페이지 attach 표시됩니다)",
2399
+ "dashboard.inspector.open": "디버그 열기",
2400
+ "dashboard.inspector.waiting": "페이지를 attach하면 \"디버그 열기\" 버튼이 표시됩니다",
2393
2401
  "inspector.error.noTarget": "연결된 페이지가 없습니다. 기기를 attach한 후 다시 시도하세요.",
2394
2402
  "inspector.error.relayDown": "relay가 활성화되지 않았습니다. start_debug로 relay를 기동하세요.",
2395
2403
  "attach.title": "AIT 디버그 세션 — QR 스캔",
@@ -2595,7 +2603,15 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0;
2595
2603
  .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
2596
2604
  .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2597
2605
  .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2598
- </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>홈 화면의 launcher PWA 아이콘으로 실행하세요 (Safari 주소창이 보이면 standalone이 아닙니다).</li><li>launcher 안의 <strong>"QR 카메라로 스캔"</strong>으로 이 QR 코드를 스캔하세요.</li><li>미니앱이 풀스크린으로 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>launcher가 설치돼 있지 않은 경우</strong> — <code>devtools.aitc.dev/launcher/</code>를 한 번 열어 홈 화면에 추가하세요</li><li><strong>카메라 앱으로 스캔하면 Safari 탭으로 열립니다 (하단 탭 바 노출)</strong> — launcher 아이콘으로 다시 실행해 인앱 스캔을 사용하세요</li><li><strong>QR이 만료된 경우 (TOTP — 코드 1개는 30초 창, 만료 후 ~3분(±6 step) 이내 소급 허용)</strong> — 새 QR을 다시 스캔하세요</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section></body></html>`;
2606
+ .inspector-link {
2607
+ display: inline-block; margin-top: 0.5rem;
2608
+ padding: 0.45rem 1rem; border-radius: 6px;
2609
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
2610
+ text-decoration: none; text-align: center;
2611
+ }
2612
+ .inspector-link:hover { background: #388bfd; }
2613
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
2614
+ </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>홈 화면의 launcher PWA 아이콘으로 실행하세요 (Safari 주소창이 보이면 standalone이 아닙니다).</li><li>launcher 안의 <strong>"QR 카메라로 스캔"</strong>으로 이 QR 코드를 스캔하세요.</li><li>미니앱이 풀스크린으로 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>launcher가 설치돼 있지 않은 경우</strong> — <code>devtools.aitc.dev/launcher/</code>를 한 번 열어 홈 화면에 추가하세요</li><li><strong>카메라 앱으로 스캔하면 Safari 탭으로 열립니다 (하단 탭 바 노출)</strong> — launcher 아이콘으로 다시 실행해 인앱 스캔을 사용하세요</li><li><strong>QR이 만료된 경우 (TOTP — 코드 1개는 30초 창, 만료 후 ~3분(±6 step) 이내 소급 허용)</strong> — 새 QR을 다시 스캔하세요</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section><hr/><section id="inspector-section"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section></body></html>`;
2599
2615
  const attachChromeHtmlKoIntoss = `<!DOCTYPE html>
2600
2616
  <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>
2601
2617
  *, *::before, *::after { box-sizing: border-box; }
@@ -2645,7 +2661,15 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0;
2645
2661
  .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
2646
2662
  .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2647
2663
  .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2648
- </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li>__LIVE_FAQ__</ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section></body></html>`;
2664
+ .inspector-link {
2665
+ display: inline-block; margin-top: 0.5rem;
2666
+ padding: 0.45rem 1rem; border-radius: 6px;
2667
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
2668
+ text-decoration: none; text-align: center;
2669
+ }
2670
+ .inspector-link:hover { background: #388bfd; }
2671
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
2672
+ </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li>__LIVE_FAQ__</ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section><hr/><section id="inspector-section"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section></body></html>`;
2649
2673
  const dashboardChromeHtmlEn = `<!DOCTYPE html>
2650
2674
  <html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>AIT Debug Dashboard</title><style>
2651
2675
  *, *::before, *::after { box-sizing: border-box; }
@@ -2755,7 +2779,15 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0;
2755
2779
  .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
2756
2780
  .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2757
2781
  .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2758
- </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Launch the launcher PWA icon on your home screen (if the Safari address bar is visible, it is not standalone).</li><li>Scan this QR code with <strong>"Scan QR with camera"</strong> inside the launcher.</li><li>The mini-app opens fullscreen and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Launcher is not installed</strong> — open <code>devtools.aitc.dev/launcher/</code> once and add it to your home screen</li><li><strong>Scanning with the camera app opens a Safari tab (bottom tab bar visible)</strong> — relaunch from the launcher icon and use the in-app scanner</li><li><strong>QR expired (TOTP — 30-second step, ±6 steps (~3 min) accepted)</strong> — scan a fresh QR code</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section></body></html>`;
2782
+ .inspector-link {
2783
+ display: inline-block; margin-top: 0.5rem;
2784
+ padding: 0.45rem 1rem; border-radius: 6px;
2785
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
2786
+ text-decoration: none; text-align: center;
2787
+ }
2788
+ .inspector-link:hover { background: #388bfd; }
2789
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
2790
+ </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Launch the launcher PWA icon on your home screen (if the Safari address bar is visible, it is not standalone).</li><li>Scan this QR code with <strong>"Scan QR with camera"</strong> inside the launcher.</li><li>The mini-app opens fullscreen and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Launcher is not installed</strong> — open <code>devtools.aitc.dev/launcher/</code> once and add it to your home screen</li><li><strong>Scanning with the camera app opens a Safari tab (bottom tab bar visible)</strong> — relaunch from the launcher icon and use the in-app scanner</li><li><strong>QR expired (TOTP — 30-second step, ±6 steps (~3 min) accepted)</strong> — scan a fresh QR code</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section><hr/><section id="inspector-section"><h2>Inspector</h2>__INSPECTOR_SECTION__</section></body></html>`;
2759
2791
  const attachChromeHtmlEnIntoss = `<!DOCTYPE html>
2760
2792
  <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>
2761
2793
  *, *::before, *::after { box-sizing: border-box; }
@@ -2805,7 +2837,15 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0;
2805
2837
  .lang-switcher { display: flex; gap: 0.5rem; font-size: 0.75rem; }
2806
2838
  .lang-switcher a { color: #58a6ff; text-decoration: none; opacity: 0.6; }
2807
2839
  .lang-switcher a.active { font-weight: 700; text-decoration: underline; opacity: 1; }
2808
- </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>"Open in Toss"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li>__LIVE_FAQ__</ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section></body></html>`;
2840
+ .inspector-link {
2841
+ display: inline-block; margin-top: 0.5rem;
2842
+ padding: 0.45rem 1rem; border-radius: 6px;
2843
+ background: #1f6feb; color: #fff; font-size: 0.85rem; font-weight: 600;
2844
+ text-decoration: none; text-align: center;
2845
+ }
2846
+ .inspector-link:hover { background: #388bfd; }
2847
+ .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
2848
+ </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>"Open in Toss"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li>__LIVE_FAQ__</ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section><hr/><section id="inspector-section"><h2>Inspector</h2>__INSPECTOR_SECTION__</section></body></html>`;
2809
2849
  /** Map from Locale to the precompiled dashboard chrome string. */
2810
2850
  const dashboardChromeByLocale = {
2811
2851
  ko: dashboardChromeHtmlKo,
@@ -2905,8 +2945,9 @@ function buildDashboardHtml(state, qrDataUrl, locale, path = "/", params = new U
2905
2945
  const copyLabel = escapeHtml(s("dashboard.url.copy"));
2906
2946
  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>`;
2907
2947
  } else attachSection = `<p class="hint">${escapeHtml(s("dashboard.attach.hint"))}</p>`;
2948
+ const pagesAttached = Array.isArray(state.pages) && state.pages.length > 0;
2908
2949
  let inspectorSection;
2909
- 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>`;
2950
+ if (pagesAttached && 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>`;
2910
2951
  else inspectorSection = `<span class="inspector-hint" id="inspector-link">${escapeHtml(s("dashboard.inspector.waiting"))}</span>`;
2911
2952
  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) => {
2912
2953
  return `<li><span class="page-id">${escapeHtml(p.id)}</span> <span class="page-url">${escapeHtml(p.url.slice(0, 120))}</span></li>`;
@@ -3076,11 +3117,15 @@ function buildSseScript(strings) {
3076
3117
  sec.innerHTML = '<p class=\\"hint\\">' + ATTACH_HINT + '</p>';`}
3077
3118
  }
3078
3119
  }
3079
- // 인스펙터 링크 갱신 — #inspector-link (#503).
3120
+ // 인스펙터 링크 갱신 — #inspector-link (#503, gate 보정 #544).
3121
+ // 게이트: pages.length > 0 (페이지 attach 여부) — inspectorUrl 존재 여부가 아님.
3122
+ // #530 이후 inspectorUrl은 항상 안정 URL이므로 null 게이트는 사실상 항상 활성이었다.
3123
+ // pages.length > 0 으로 바꿔 미attach 시 대기 힌트를 보여주도록 수정.
3080
3124
  // SECRET-HANDLING: inspectorUrl을 console.log 등으로 출력하지 않는다.
3081
3125
  var insp = document.getElementById('inspector-link');
3082
3126
  if (insp) {
3083
- if (s.inspectorUrl) {
3127
+ var pagesAttachedSse = Array.isArray(s.pages) && s.pages.length > 0;
3128
+ if (pagesAttachedSse && s.inspectorUrl) {
3084
3129
  var safeInspUrl = String(s.inspectorUrl).slice(0, 2000).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
3085
3130
  insp.outerHTML = '<a class=\\"inspector-link\\" id=\\"inspector-link\\" href=\\"' + safeInspUrl + '\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">' + INSPECTOR_OPEN_LABEL + '</a>';
3086
3131
  } else {
@@ -3102,28 +3147,33 @@ function buildSseScript(strings) {
3102
3147
  * Attach 페이지 HTML — precompiled chrome에 per-request 동적 값을 채워 완성한다.
3103
3148
  *
3104
3149
  * 동적 파트:
3105
- * - __QR_DATA_URL__ : base64 data URL (QR 이미지)
3106
- * - __SAFE_LABEL__ : HTML-escaped deploymentId label (intoss family에만 존재)
3107
- * - __SAFE_ATTACH_URL__ : HTML-escaped attach URL (TOTP at= 코드 포함 — 의도된 전달)
3108
- * - __MODE_LABEL__ : 환경 배지 (`<p class="mode-label">…</p>` 또는 빈 문자열, #468)
3109
- * - __LIVE_FAQ__ : 환경 4 LIVE read-only `<li>` 또는 빈 문자열 (intoss family에만 존재)
3150
+ * - __QR_DATA_URL__ : base64 data URL (QR 이미지)
3151
+ * - __SAFE_LABEL__ : HTML-escaped deploymentId label (intoss family에만 존재)
3152
+ * - __SAFE_ATTACH_URL__ : HTML-escaped attach URL (TOTP at= 코드 포함 — 의도된 전달)
3153
+ * - __MODE_LABEL__ : 환경 배지 (`<p class="mode-label">…</p>` 또는 빈 문자열, #468)
3154
+ * - __LIVE_FAQ__ : 환경 4 LIVE read-only `<li>` 또는 빈 문자열 (intoss family에만 존재)
3155
+ * - __INSPECTOR_SECTION__ : "디버그 툴 열기" 버튼 또는 대기 힌트 (#544)
3110
3156
  *
3111
3157
  * mode-aware 분기 (#468): mode가 `relay-mobile`이면 sandbox family chrome(launcher
3112
3158
  * PWA 절차), 그 외는 intoss family chrome(토스 앱 절차)을 선택한다. `relay-live`는
3113
3159
  * intoss chrome에 LIVE read-only 라인을 추가한다.
3114
3160
  *
3115
3161
  * SSE 스크립트도 주입 — `#attach-section` hook이 있으면 `/events` push 때 QR이
3116
- * `/qr.png?u=<fresh attachUrl>`로 자동 갱신된다. `#tunnel-status`·`#pages-list`
3117
- * 나머지 selector는 /attach 페이지에 없으므로 null-guard로 no-op.
3162
+ * `/qr.png?u=<fresh attachUrl>`로 자동 갱신된다. `#inspector-link`도 SSE push로
3163
+ * pages.length > 0 게이트에 따라 활성/비활성 전환된다 (#544).
3118
3164
  *
3119
3165
  * SECRET-HANDLING: TOTP at= 코드는 attachUrl 캡슐 안에서만 노출 — 의도된 transport.
3166
+ * inspectorStableUrl은 /inspector 안정 URL (127.0.0.1, 시크릿 없음) — 노출 가능.
3120
3167
  */
3121
- function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/attach", params = new URLSearchParams(), mode) {
3168
+ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/attach", params = new URLSearchParams(), mode, pagesAttached = false, inspectorStableUrl = null) {
3122
3169
  const s = resolveLocaleStrings(locale);
3123
3170
  const langSwitcher = buildLangSwitcher(path, params, locale, s);
3124
3171
  const family = attachFamilyForMode(mode);
3125
3172
  const liveFaq = mode === "relay-live" ? `<li>${s("attach.intoss.faq.liveReadOnly")}</li>` : "";
3126
- const filled = attachChromeByLocale[locale][family].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__MODE_LABEL__", buildModeLabel(mode, s)).replaceAll("__LIVE_FAQ__", liveFaq).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl);
3173
+ let inspectorSection;
3174
+ 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>`;
3175
+ else inspectorSection = `<span class="inspector-hint" id="inspector-link">${escapeHtml(s("dashboard.inspector.waiting"))}</span>`;
3176
+ const filled = attachChromeByLocale[locale][family].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__MODE_LABEL__", buildModeLabel(mode, s)).replaceAll("__LIVE_FAQ__", liveFaq).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl).replaceAll("__INSPECTOR_SECTION__", inspectorSection);
3127
3177
  const sseScript = buildSseScript({
3128
3178
  tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
3129
3179
  tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
@@ -3225,12 +3275,20 @@ async function startQrHttpServer(getDashboardState, options) {
3225
3275
  const dpMatch = attachUrl.match(/[?&]_deploymentId=([^&]+)/);
3226
3276
  if (dpMatch?.[1]) deploymentIdLabel = decodeURIComponent(dpMatch[1]).slice(0, 36);
3227
3277
  } catch {}
3228
- const mode = getDashboardState?.().mode;
3278
+ const currentState = getDashboardState?.();
3279
+ const mode = currentState?.mode;
3280
+ const pagesAttached = Array.isArray(currentState?.pages) && (currentState?.pages.length ?? 0) > 0;
3281
+ const inspectorStableUrlForAttach = (() => {
3282
+ if (!options?.getDirectInspectorUrl) return null;
3283
+ const addr = server.address();
3284
+ if (!addr || typeof addr === "string") return null;
3285
+ return `http://127.0.0.1:${addr.port}/inspector`;
3286
+ })();
3229
3287
  QRCode.toDataURL(attachUrl, {
3230
3288
  type: "image/png",
3231
3289
  errorCorrectionLevel: "M"
3232
3290
  }).then((dataUrl) => {
3233
- const html = buildAttachHtml(dataUrl, escapeHtml(deploymentIdLabel), escapeHtml(attachUrl), locale, path, params, mode);
3291
+ const html = buildAttachHtml(dataUrl, escapeHtml(deploymentIdLabel), escapeHtml(attachUrl), locale, path, params, mode, pagesAttached, inspectorStableUrlForAttach);
3234
3292
  res.writeHead(200, {
3235
3293
  "Content-Type": "text/html; charset=utf-8",
3236
3294
  "Cache-Control": "no-store"
@@ -3703,7 +3761,7 @@ const DEBUG_TOOL_DEFINITIONS = [
3703
3761
  },
3704
3762
  {
3705
3763
  name: "build_attach_url",
3706
- description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Builds a self-attaching deep-link for the active relay environment and returns a QR code. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Call list_pages first to confirm the relay/tunnel is up. If the tunnel is not up, restart: `npx @ait-co/devtools devtools-mcp`.\n\nEnvironment-specific behaviour:\n • env 3 / relay-staging (start_debug mode=\"relay-staging\"): requires scheme_url — the intoss-private://…?_deploymentId=<uuid> URL from `ait deploy --scheme-only`. Splices debug=1 + relay URL into the scheme URL to produce a self-attach deep-link.\n • env 2 / relay-sandbox (start_debug mode=\"relay-sandbox\"): scheme_url is NOT used. Instead, reads AIT_TUNNEL_BASE_URL (the https://*.trycloudflare.com app tunnel from `tunnel:{cdp:true}`) and builds a launcher PWA deep-link (https://devtools.aitc.dev/launcher/?url=…&debug=1&relay=…). When projectRoot is given, the app name from <projectRoot>/package.json is automatically added as name= so the launcher partner bar shows it. Scan the QR with the phone to open the launcher, which frames the tunnel URL and attaches CDP.\n\nSet wait_for_attach=true to block until a page attaches (polls up to 30 s). On timeout, call build_attach_url again to resume polling. When open_in_browser=true (default), saves the QR as a PNG and opens it in the OS default browser — only works when the MCP server runs on a local GUI machine (not headless/remote containers). \n\nTOTP auth: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the returned attachUrl automatically includes the current one-time code (at=<code>). The code is valid for ~3 minutes (the relay gate accepts ±6 TOTP steps = 180–210 s of backwards acceptance). The response includes a `totp` field with `expiresAt` (ISO timestamp, ~3 min from issuance). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh URL. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.",
3764
+ description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Builds a self-attaching deep-link for the active relay environment and returns a QR code. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Call list_pages first to confirm the relay/tunnel is up. If the tunnel is not up, restart: `npx @ait-co/devtools devtools-mcp`.\n\nEnvironment-specific behaviour:\n • env 3 / relay-staging (start_debug mode=\"relay-staging\"): requires scheme_url — the intoss-private://…?_deploymentId=<uuid> URL from `ait deploy --scheme-only`. Splices debug=1 + relay URL into the scheme URL to produce a self-attach deep-link.\n • env 2 / relay-sandbox (start_debug mode=\"relay-sandbox\"): scheme_url is NOT used. Instead, reads AIT_TUNNEL_BASE_URL (the https://*.trycloudflare.com app tunnel from `tunnel:{cdp:true}`) and builds a launcher PWA deep-link (https://devtools.aitc.dev/launcher/?url=…&debug=1&relay=…). When projectRoot is given, the app name from <projectRoot>/package.json is automatically added as name= so the launcher partner bar shows it. Scan the QR with the phone to open the launcher, which frames the tunnel URL and attaches CDP.\n\nSet wait_for_attach=true to block until a page attaches (polls up to 30 s). On timeout, call build_attach_url again to resume polling. When open_in_browser=true (default), saves the QR as a PNG and opens it in the OS default browser — only works when the MCP server runs on a local GUI machine (not headless/remote containers). \n\nTOTP auth: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the returned attachUrl automatically includes the current one-time code (at=<code>). The code is valid for ~3 minutes (the relay gate accepts ±6 TOTP steps = 180–210 s of backwards acceptance). The response includes a `totp` field with `expiresAt` (ISO timestamp, ~3 min from issuance). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh URL. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.\n\nselfdebug (env 2 / relay-sandbox only): pass selfdebug=true to add &selfdebug=1 to the launcher deep-link. The launcher PWA then registers its own document as the CDP target instead of the framed mini-app. SINGLE-ATTACH MODEL: attaching the launcher self-target evicts any currently-attached mini-app target — use this mode exclusively for diagnosing the launcher document itself (DOM, safe-area, console). Not applicable in env 3/4 (relay-staging/relay-live) — passing selfdebug=true there returns an error.",
3707
3765
  inputSchema: {
3708
3766
  type: "object",
3709
3767
  properties: {
@@ -3722,6 +3780,10 @@ const DEBUG_TOOL_DEFINITIONS = [
3722
3780
  projectRoot: {
3723
3781
  type: "string",
3724
3782
  description: "Absolute path to the mini-app project root (the directory containing its package.json and .ait_urls). When AIT_TUNNEL_BASE_URL is unset (env 2 / relay-mobile only), the daemon reads the app tunnel URL from <projectRoot>/.ait_urls written by the dev server (tunnel:{cdp:true}). Pass this because the daemon's own cwd is fixed at launch. Omit when AIT_TUNNEL_BASE_URL is set explicitly."
3783
+ },
3784
+ selfdebug: {
3785
+ type: "boolean",
3786
+ description: "Env 2 / relay-sandbox only. When true, adds &selfdebug=1 to the launcher deep-link so the launcher PWA registers its own document as the CDP target (launcher diagnostics mode). SINGLE-ATTACH MODEL: self-target attach evicts any currently-attached mini-app target. Use only when you need to inspect the launcher itself (DOM, safe-area, console). Passing selfdebug=true in env 3/4 (relay-staging/relay-live) returns an error. Default: false (omitted — output is byte-identical to previous behaviour)."
3725
3787
  }
3726
3788
  },
3727
3789
  required: []
@@ -4717,7 +4779,7 @@ async function readMcpSdkVersion() {
4717
4779
  * some test environments that skip the build step).
4718
4780
  */
4719
4781
  function readDevtoolsVersion() {
4720
- return "0.1.81";
4782
+ return "0.1.84";
4721
4783
  }
4722
4784
  /**
4723
4785
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -5221,7 +5283,7 @@ function createDebugServer(deps) {
5221
5283
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
5222
5284
  const server = new Server({
5223
5285
  name: "ait-debug",
5224
- version: "0.1.81"
5286
+ version: "0.1.84"
5225
5287
  }, { capabilities: { tools: { listChanged: true } } });
5226
5288
  server.setRequestHandler(ListToolsRequestSchema, () => {
5227
5289
  const conn = router.active;
@@ -5295,6 +5357,8 @@ function createDebugServer(deps) {
5295
5357
  if (name === "build_attach_url") {
5296
5358
  const waitForAttach = request.params.arguments?.wait_for_attach === true;
5297
5359
  const openInBrowser = request.params.arguments?.open_in_browser !== false;
5360
+ const selfdebug = request.params.arguments?.selfdebug === true;
5361
+ if (selfdebug && env !== "relay-mobile") return mcpError("build_attach_url: selfdebug=true는 env 2 / relay-sandbox 전용 기능입니다. 현재 환경(env 3/4)에서는 launcher가 없어 self-target 모드를 지원하지 않습니다. launcher self-target이 필요하다면 relay-sandbox 모드로 재시작하세요.");
5298
5362
  if (env === "relay-mobile") {
5299
5363
  const rawBuildProjectRoot = request.params.arguments?.projectRoot;
5300
5364
  const buildProjectRoot = typeof rawBuildProjectRoot === "string" ? rawBuildProjectRoot : void 0;
@@ -5329,7 +5393,10 @@ function createDebugServer(deps) {
5329
5393
  const rawName = typeof pkg.name === "string" ? pkg.name : "";
5330
5394
  launcherAppName = (rawName.includes("/") ? rawName.slice(rawName.indexOf("/") + 1) : rawName).trim() || void 0;
5331
5395
  } catch {}
5332
- const attachUrl = buildLauncherAttachUrl(tunnelHttpUrl, tunnelStatus.wssUrl, totpCode, { name: launcherAppName });
5396
+ const attachUrl = buildLauncherAttachUrl(tunnelHttpUrl, tunnelStatus.wssUrl, totpCode, {
5397
+ name: launcherAppName,
5398
+ ...selfdebug ? { selfdebug: true } : {}
5399
+ });
5333
5400
  onAttachUrlBuilt?.({
5334
5401
  kind: "launcher",
5335
5402
  tunnelHttpUrl,
@@ -7264,7 +7331,7 @@ function createDevServer(deps = {}) {
7264
7331
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
7265
7332
  const server = new Server({
7266
7333
  name: "ait-devtools",
7267
- version: "0.1.81"
7334
+ version: "0.1.84"
7268
7335
  }, { capabilities: { tools: {} } });
7269
7336
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
7270
7337
  server.setRequestHandler(CallToolRequestSchema, async (request) => {