@ait-co/devtools 0.1.51 → 0.1.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp/cli.js CHANGED
@@ -213,6 +213,8 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
213
213
  * opens a client websocket to it, enables Phase 1 domains, and buffers events.
214
214
  */
215
215
  var ChiiCdpConnection = class {
216
+ /** Authoritative connection kind (issue #348) — relay-backed. */
217
+ kind = "relay";
216
218
  relayBaseUrl;
217
219
  bufferSize;
218
220
  commandTimeoutMs;
@@ -909,7 +911,7 @@ function isLiveRelayEnv(env) {
909
911
  return env === "relay-live";
910
912
  }
911
913
  /**
912
- * Maps the new `McpEnvironment` union to the legacy two-value union
914
+ * Maps the `McpEnvironment` union to the legacy two-value union
913
915
  * (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
914
916
  */
915
917
  function toLegacyEnv(env) {
@@ -917,93 +919,42 @@ function toLegacyEnv(env) {
917
919
  return "relay";
918
920
  }
919
921
  /**
920
- * URL patterns that mark a CDP target as a real-device WebView relay.
922
+ * Reconstructs the three-value `McpEnvironment` output string from the two
923
+ * orthogonal signals (issue #348):
921
924
  *
922
- * - `intoss-private://` is the Toss in-app private scheme — only ever observed
923
- * inside the real Toss app WebView.
924
- * - `*.trycloudflare.com` (host suffix) is the cloudflared quick tunnel used as
925
- * the relay transport. A target whose URL is on that host is, by construction,
926
- * reached over the relay.
925
+ * - `kind === 'local'` → `'mock'`
926
+ * - `kind === 'relay'` && liveIntent → `'relay-live'`
927
+ * - `kind === 'relay'` && !liveIntent → `'relay-dev'`
927
928
  *
928
- * Pattern-only matches no specific tunnel host or deploymentId is hard-coded.
929
+ * Pureused at every output boundary (envelope `meta.env`, `get_diagnostics`,
930
+ * `measure_safe_area` provenance) so the surface never sniffs a URL again.
929
931
  */
930
- const RELAY_URL_PATTERNS = [/^intoss-private:\/\//i, /:\/\/[a-z0-9-]+\.trycloudflare\.com(\/|$|:|\?)/i];
931
- /**
932
- * Returns true when the URL string looks like a real-device WebView attached
933
- * over the Chii relay. Used for `getEnvironment()` precedence step 2.
934
- */
935
- function isRelayUrl(url) {
936
- if (typeof url !== "string" || url.length === 0) return false;
937
- return RELAY_URL_PATTERNS.some((p) => p.test(url));
932
+ function deriveEnvironment(kind, liveIntent) {
933
+ if (kind === "local") return "mock";
934
+ return liveIntent ? "relay-live" : "relay-dev";
938
935
  }
939
936
  /**
940
- * Test/override hook when non-null, `getEnvironment()` returns this value
941
- * regardless of env vars or connection state. Cleared with `null`.
942
- */
943
- let envOverride = null;
944
- /**
945
- * Parses the `MCP_ENV` env var into a `McpEnvironment` if valid.
937
+ * Module-level `relay-dev` vs `relay-live` intent bit (issue #348).
946
938
  *
947
- * Accepted values:
948
- * - `mock` `mock`
949
- * - `relay-dev` → `relay-dev`
950
- * - `relay-live` → `relay-live`
951
- * - `relay` → `relay-dev` (backward-compat alias — resolves to relay-dev)
939
+ * Armed by `start_debug({ mode: 'relay-live' })` (and seeded at boot by the
940
+ * deprecated `MCP_ENV=relay-live` alias). Disarming is implicit: when the
941
+ * active connection becomes local, the LIVE guard reads
942
+ * `connection.kind === 'relay' && liveIntent`, so a stale `true` bit is inert.
952
943
  *
953
- * Any other value is ignored and falls through to the next precedence step.
944
+ * SECRET-HANDLING: this is a boolean never a secret. Safe to read in logs.
954
945
  */
955
- function readEnvVar() {
956
- const raw = process.env.MCP_ENV;
957
- if (raw === "mock") return "mock";
958
- if (raw === "relay-dev") return "relay-dev";
959
- if (raw === "relay-live") return "relay-live";
960
- if (raw === "relay") return "relay-dev";
946
+ let liveIntent = false;
947
+ /** Returns the current `liveIntent` bit. */
948
+ function getLiveIntent() {
949
+ return liveIntent;
961
950
  }
962
951
  /**
963
- * Returns the current MCP environment, applying the precedence rules:
964
- * 1. test override (if set)
965
- * 2. `MCP_ENV` env var
966
- * 3. CDP target URL pattern match → `relay-dev` (conservative — LIVE
967
- * requires explicit MCP_ENV=relay-live opt-in)
968
- * 4. caller-stated `defaultEnv` (intent hint from the CLI mode)
969
- * 5. baked-in default `mock`
952
+ * Sets the `liveIntent` bit. Called by `start_debug` (true for `relay-live`,
953
+ * false for every other mode) and once at boot by the `MCP_ENV=relay-live`
954
+ * deprecated alias.
970
955
  */
971
- function getEnvironment(input = {}) {
972
- if (envOverride !== null) return envOverride;
973
- const fromEnv = readEnvVar();
974
- if (fromEnv !== void 0) return fromEnv;
975
- const { connection, defaultEnv } = input;
976
- if (connection !== void 0) {
977
- const targets = connection.listTargets();
978
- for (const t of targets) if (isRelayUrl(t.url)) return "relay-dev";
979
- }
980
- return defaultEnv ?? "mock";
981
- }
982
- /**
983
- * Returns the `EnvironmentReason` that drove the current `getEnvironment()`
984
- * result. Used by stderr logs and the rejection-reason payload on Tier A/B
985
- * mismatch errors. SECRET-HANDLING: only stable enum strings — no URL or
986
- * secret value is ever returned.
987
- */
988
- function getEnvironmentReason(input = {}) {
989
- if (envOverride !== null) {
990
- if (envOverride === "mock") return "env-var-mock";
991
- if (envOverride === "relay-live") return "env-var-relay-live";
992
- return "env-var-relay-dev";
993
- }
994
- const rawVar = process.env.MCP_ENV;
995
- const fromEnv = readEnvVar();
996
- if (fromEnv === "mock") return "env-var-mock";
997
- if (fromEnv === "relay-live") return "env-var-relay-live";
998
- if (fromEnv === "relay-dev") return rawVar === "relay" ? "env-var-relay-compat" : "env-var-relay-dev";
999
- const { connection, defaultEnv } = input;
1000
- if (connection !== void 0) {
1001
- const targets = connection.listTargets();
1002
- for (const t of targets) if (isRelayUrl(t.url)) return "cdp-target-url-relay-pattern";
1003
- }
1004
- if (defaultEnv === "relay-live") return "default-relay-live";
1005
- if (defaultEnv === "relay-dev") return "default-relay-dev";
1006
- return "default-mock";
956
+ function setLiveIntent(value) {
957
+ liveIntent = value;
1007
958
  }
1008
959
  //#endregion
1009
960
  //#region src/mcp/errors.ts
@@ -1027,7 +978,8 @@ function mcpError(message) {
1027
978
  * @param toolName - 거부된 tool 이름.
1028
979
  * @param requiredEnv - 해당 tool이 요구하는 환경 ('mock' | 'relay').
1029
980
  * @param currentEnv - 현재 세션 환경.
1030
- * @param reason - 환경이 결정된 근거 (EnvironmentReason 문자열).
981
+ * @param reason - 환경이 결정된 근거를 나타내는 파생 문자열
982
+ * (예: `derived:kind=relay,liveIntent=true`).
1031
983
  */
1032
984
  function tierRejectionError(toolName, requiredEnv, currentEnv, reason) {
1033
985
  return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "relay로 전환하려면 MCP_ENV=relay 설정 후 서버를 재시작하고 build_attach_url → QR 스캔으로 실기기를 attach하세요." : "mock으로 전환하려면 MCP_ENV=mock 설정 후 서버를 재시작하세요."}`}\n\n${`tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`}`);
@@ -1162,6 +1114,8 @@ const PHASE_1_EVENTS = [
1162
1114
  * `about:blank`, `about:newtab`, or a devtools:// URL.
1163
1115
  */
1164
1116
  var LocalCdpConnection = class {
1117
+ /** Authoritative connection kind (issue #348) — local Chromium CDP. */
1118
+ kind = "local";
1165
1119
  devtoolsHttpUrl;
1166
1120
  bufferSize;
1167
1121
  emitter = new EventEmitter();
@@ -2317,6 +2271,31 @@ const DEBUG_TOOL_DEFINITIONS = [
2317
2271
  },
2318
2272
  availableIn: "both"
2319
2273
  },
2274
+ {
2275
+ name: "start_debug",
2276
+ description: "Switches the active debug environment in-place (issue #348) — no Claude Code restart and no MCP re-handshake. One daemon holds both a local (env 1, mock SDK in a Chromium) and a relay (env 3/4, real-device Toss WebView over the Chii relay + cloudflared tunnel) connection at once; this tool flips which one every other tool reads from, lazily booting the requested family's infra on first use and keeping the inactive one warm so an existing attach survives the switch. After switching it emits notifications/tools/list_changed — call tools/list again to see the updated tool surface for the new environment.\n\nmodes:\n local-browser-dev / local-browser-cdp — local Chromium CDP attach (env 1, mock). Both route to the local connection; the names preserve dev-vs-cdp intent.\n relay-dev — real-device dogfood relay (env 3). Side-effect tools run unguarded.\n relay-live — real-device live/production relay (env 4, read-only debugging). Arms the LIVE guard: call_sdk/evaluate then require confirm: true. Entering relay-live ALSO requires confirm: true on this call to acknowledge LIVE intent.\n\nSwitching back to a local mode automatically disarms the LIVE guard.",
2277
+ inputSchema: {
2278
+ type: "object",
2279
+ properties: {
2280
+ mode: {
2281
+ type: "string",
2282
+ enum: [
2283
+ "local-browser-dev",
2284
+ "local-browser-cdp",
2285
+ "relay-dev",
2286
+ "relay-live"
2287
+ ],
2288
+ description: "Target environment to switch to. relay-live additionally requires confirm: true."
2289
+ },
2290
+ confirm: {
2291
+ type: "boolean",
2292
+ description: "Required when mode=relay-live — set true to acknowledge entering LIVE (env 4) debugging that can affect real users. Ignored for the other modes."
2293
+ }
2294
+ },
2295
+ required: ["mode"]
2296
+ },
2297
+ availableIn: "both"
2298
+ },
2320
2299
  {
2321
2300
  name: "get_diagnostics",
2322
2301
  description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (kind: mock|relay-dev|relay-live, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active), serverLockHolder (pid + startedAt from the lock file, or null), nextRecommendedAction ({tool, reason} or null — the single next tool to call; in local-target mode tunnel.up=false is normal so \"restart\" is never recommended). All fields are nullable — missing data is null, not an error. debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. Tier C (both mock and relay). Call this first when debugging session state.",
@@ -2381,7 +2360,8 @@ function filterToolsByEnvironment(tools, env) {
2381
2360
  const BOOTSTRAP_TOOL_NAMES = new Set([
2382
2361
  "build_attach_url",
2383
2362
  "get_diagnostics",
2384
- "list_pages"
2363
+ "list_pages",
2364
+ "start_debug"
2385
2365
  ]);
2386
2366
  /** Renders a CDP `RemoteObject` console arg to a stable display string. */
2387
2367
  function renderRemoteObject(arg) {
@@ -3559,6 +3539,10 @@ function extractDeploymentId(schemeUrl) {
3559
3539
  return null;
3560
3540
  }
3561
3541
  }
3542
+ /** Returns `true` when the mode routes to a relay connection. */
3543
+ function isRelayMode(mode) {
3544
+ return mode === "relay-dev" || mode === "relay-live";
3545
+ }
3562
3546
  /**
3563
3547
  * Waits for the first target matching `filterFn` to attach, using the
3564
3548
  * event-driven `waitForFirstTarget()` when the connection supports it
@@ -3613,23 +3597,19 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
3613
3597
  * naturally via `enableDomains`). The tier only controls visibility.
3614
3598
  */
3615
3599
  function createDebugServer(deps) {
3616
- const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, defaultEnv, totpSecret } = deps;
3617
- const resolveEnvironment = getEnvDep ?? (() => getEnvironment({
3618
- connection,
3619
- defaultEnv
3620
- }));
3621
- const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({
3622
- connection,
3623
- defaultEnv
3624
- }));
3600
+ const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret } = deps;
3601
+ const router = routerDep ?? makeSingleConnectionRouter(connection);
3602
+ const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent()));
3603
+ const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},liveIntent=${getLiveIntent()}`);
3625
3604
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
3626
3605
  const server = new Server({
3627
3606
  name: "ait-debug",
3628
- version: "0.1.51"
3607
+ version: "0.1.52"
3629
3608
  }, { capabilities: { tools: { listChanged: true } } });
3630
3609
  server.setRequestHandler(ListToolsRequestSchema, () => {
3610
+ const conn = router.active;
3631
3611
  const env = resolveEnvironment();
3632
- const attached = connection.listTargets().length > 0;
3612
+ const attached = conn.listTargets().length > 0;
3633
3613
  const envFiltered = filterToolsByEnvironment(DEBUG_TOOL_DEFINITIONS, env);
3634
3614
  return { tools: attached ? envFiltered.map((tool) => ({ ...tool })) : envFiltered.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
3635
3615
  });
@@ -3642,10 +3622,22 @@ function createDebugServer(deps) {
3642
3622
  }],
3643
3623
  isError: true
3644
3624
  };
3625
+ const conn = router.active;
3626
+ if (name === "start_debug") {
3627
+ const rawMode = request.params.arguments?.mode;
3628
+ const mode = normalizeStartDebugMode(rawMode);
3629
+ if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser-dev' | 'local-browser-cdp' | 'relay-dev' | 'relay-live' 중 하나를 전달하세요.");
3630
+ const confirm = request.params.arguments?.confirm === true;
3631
+ try {
3632
+ return jsonResult$1(await router.switchMode(mode, confirm));
3633
+ } catch (err) {
3634
+ return errorResult(err, name);
3635
+ }
3636
+ }
3645
3637
  const env = resolveEnvironment();
3638
+ const envReason = resolveEnvironmentReason();
3646
3639
  if (!isToolAvailableIn(name, env)) {
3647
3640
  const requiredEnv = getToolAvailability(name) ?? "unknown";
3648
- const envReason = resolveEnvironmentReason();
3649
3641
  logWarn("tool.error", {
3650
3642
  tool: name,
3651
3643
  errorKind: "tier-filter",
@@ -3656,7 +3648,7 @@ function createDebugServer(deps) {
3656
3648
  return tierRejectionError(name, requiredEnv, env, envReason);
3657
3649
  }
3658
3650
  if (isAitToolName(name)) try {
3659
- await connection.enableDomains();
3651
+ await conn.enableDomains();
3660
3652
  switch (name) {
3661
3653
  case "AIT.getSdkCallHistory": return jsonResult$1(await getSdkCallHistory(aitSource));
3662
3654
  case "AIT.getMockState": return jsonResult$1(await getMockState(aitSource));
@@ -3669,17 +3661,15 @@ function createDebugServer(deps) {
3669
3661
  if (name === "get_diagnostics") try {
3670
3662
  const rawLimit = request.params.arguments?.recent_errors_limit;
3671
3663
  const recentErrorsLimit = typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 10;
3672
- const result = await getDiagnostics({
3664
+ return envelopeResult$1(await getDiagnostics({
3673
3665
  tunnel: getTunnelStatus(),
3674
- connection,
3675
- env: resolveEnvironment(),
3676
- envReason: resolveEnvironmentReason(),
3666
+ connection: conn,
3667
+ env,
3668
+ envReason,
3677
3669
  collector,
3678
3670
  readLock: readServerLock,
3679
3671
  recentErrorsLimit
3680
- });
3681
- const attached = connection.listTargets().length > 0;
3682
- return envelopeResult$1(result, name, resolveEnvironment(), attached);
3672
+ }), name, env, conn.listTargets().length > 0);
3683
3673
  } catch (err) {
3684
3674
  return errorResult(err, name);
3685
3675
  }
@@ -3724,9 +3714,9 @@ function createDebugServer(deps) {
3724
3714
  }] };
3725
3715
  let attachedPagesHl = [];
3726
3716
  try {
3727
- attachedPagesHl = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3717
+ attachedPagesHl = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
3728
3718
  } catch {
3729
- attachedPagesHl = connection.listTargets();
3719
+ attachedPagesHl = conn.listTargets();
3730
3720
  return {
3731
3721
  content: [{
3732
3722
  type: "text",
@@ -3735,7 +3725,7 @@ function createDebugServer(deps) {
3735
3725
  isError: true
3736
3726
  };
3737
3727
  }
3738
- const pagesResultHl = listPages(connection, getTunnelStatus());
3728
+ const pagesResultHl = listPages(conn, getTunnelStatus());
3739
3729
  return { content: [{
3740
3730
  type: "text",
3741
3731
  text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
@@ -3761,9 +3751,9 @@ function createDebugServer(deps) {
3761
3751
  }] };
3762
3752
  let attachedPages = [];
3763
3753
  try {
3764
- attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3754
+ attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
3765
3755
  } catch {
3766
- attachedPages = connection.listTargets();
3756
+ attachedPages = conn.listTargets();
3767
3757
  return {
3768
3758
  content: [{
3769
3759
  type: "text",
@@ -3772,7 +3762,7 @@ function createDebugServer(deps) {
3772
3762
  isError: true
3773
3763
  };
3774
3764
  }
3775
- const pagesResult = listPages(connection, getTunnelStatus());
3765
+ const pagesResult = listPages(conn, getTunnelStatus());
3776
3766
  return { content: [{
3777
3767
  type: "text",
3778
3768
  text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
@@ -3801,9 +3791,9 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3801
3791
  }] };
3802
3792
  let attachedPagesFb = [];
3803
3793
  try {
3804
- attachedPagesFb = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3794
+ attachedPagesFb = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
3805
3795
  } catch {
3806
- attachedPagesFb = connection.listTargets();
3796
+ attachedPagesFb = conn.listTargets();
3807
3797
  return {
3808
3798
  content: [{
3809
3799
  type: "text",
@@ -3812,7 +3802,7 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3812
3802
  isError: true
3813
3803
  };
3814
3804
  }
3815
- const pagesResultFb = listPages(connection, getTunnelStatus());
3805
+ const pagesResultFb = listPages(conn, getTunnelStatus());
3816
3806
  return { content: [{
3817
3807
  type: "text",
3818
3808
  text: `${baseText}\n\n${JSON.stringify(pagesResultFb, null, 2)}`
@@ -3830,9 +3820,9 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3830
3820
  }] };
3831
3821
  let attachedPages = [];
3832
3822
  try {
3833
- attachedPages = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
3823
+ attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
3834
3824
  } catch {
3835
- attachedPages = connection.listTargets();
3825
+ attachedPages = conn.listTargets();
3836
3826
  return {
3837
3827
  content: [{
3838
3828
  type: "text",
@@ -3841,7 +3831,7 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3841
3831
  isError: true
3842
3832
  };
3843
3833
  }
3844
- const pagesResult = listPages(connection, getTunnelStatus());
3834
+ const pagesResult = listPages(conn, getTunnelStatus());
3845
3835
  return { content: [{
3846
3836
  type: "text",
3847
3837
  text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
@@ -3851,65 +3841,55 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3851
3841
  }
3852
3842
  }
3853
3843
  try {
3854
- await connection.enableDomains();
3844
+ await conn.enableDomains();
3855
3845
  } catch (err) {
3856
3846
  if (name === "list_pages") {
3857
3847
  try {
3858
- await connection.refreshTargets?.();
3848
+ await conn.refreshTargets?.();
3859
3849
  } catch {}
3860
- const pagesData = listPages(connection, getTunnelStatus());
3861
- const attached = connection.listTargets().length > 0;
3862
- return envelopeResult$1(pagesData, name, resolveEnvironment(), attached);
3850
+ return envelopeResult$1(listPages(conn, getTunnelStatus()), name, env, conn.listTargets().length > 0);
3863
3851
  }
3864
3852
  return classifyEnableDomainError(err, name);
3865
3853
  }
3866
3854
  try {
3867
3855
  switch (name) {
3868
- case "list_console_messages": return jsonResult$1(listConsoleMessages(connection));
3856
+ case "list_console_messages": return jsonResult$1(listConsoleMessages(conn));
3869
3857
  case "list_exceptions": {
3870
3858
  const rawLimit = request.params.arguments?.limit;
3871
- return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
3859
+ return jsonResult$1({ exceptions: listExceptions(conn, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
3872
3860
  }
3873
- case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
3874
- case "list_pages": {
3861
+ case "list_network_requests": return jsonResult$1(listNetworkRequests(conn));
3862
+ case "list_pages":
3875
3863
  try {
3876
- await connection.refreshTargets?.();
3864
+ await conn.refreshTargets?.();
3877
3865
  } catch {}
3878
- const listPagesData = listPages(connection, getTunnelStatus());
3879
- const listPagesAttached = connection.listTargets().length > 0;
3880
- return envelopeResult$1(listPagesData, name, resolveEnvironment(), listPagesAttached);
3881
- }
3882
- case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
3883
- case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
3866
+ return envelopeResult$1(listPages(conn, getTunnelStatus()), name, env, conn.listTargets().length > 0);
3867
+ case "get_dom_document": return jsonResult$1(await getDomDocument(conn));
3868
+ case "take_snapshot": return jsonResult$1(await takeSnapshot(conn));
3884
3869
  case "take_screenshot": {
3885
- const shot = await takeScreenshot(connection);
3870
+ const shot = await takeScreenshot(conn);
3886
3871
  return { content: [{
3887
3872
  type: "image",
3888
3873
  data: shot.data,
3889
3874
  mimeType: shot.mimeType
3890
3875
  }] };
3891
3876
  }
3892
- case "measure_safe_area": {
3893
- const safeAreaData = await measureSafeArea(connection, resolveEnvironment());
3894
- const safeAreaAttached = connection.listTargets().length > 0;
3895
- return envelopeResult$1(safeAreaData, name, resolveEnvironment(), safeAreaAttached);
3896
- }
3877
+ case "measure_safe_area": return envelopeResult$1(await measureSafeArea(conn, env), name, env, conn.listTargets().length > 0);
3897
3878
  case "evaluate": {
3898
3879
  const expression = request.params.arguments?.expression;
3899
3880
  if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
3900
- if (isLiveRelayEnv(env) && request.params.arguments?.confirm !== true) return liveGuardError("evaluate");
3901
- return jsonResult$1(await evaluate(connection, expression));
3881
+ if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("evaluate");
3882
+ return jsonResult$1(await evaluate(conn, expression));
3902
3883
  }
3903
3884
  case "call_sdk": {
3904
3885
  const sdkName = request.params.arguments?.name;
3905
3886
  if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
3906
3887
  const rawArgs = request.params.arguments?.args;
3907
3888
  const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
3908
- if (isLiveRelayEnv(env) && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
3909
- const sdkResult = await callSdk(connection, sdkName, sdkArgs);
3889
+ if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
3890
+ const sdkResult = await callSdk(conn, sdkName, sdkArgs);
3910
3891
  if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
3911
- const callSdkAttached = connection.listTargets().length > 0;
3912
- return envelopeResult$1(sdkResult, name, resolveEnvironment(), callSdkAttached);
3892
+ return envelopeResult$1(sdkResult, name, env, conn.listTargets().length > 0);
3913
3893
  }
3914
3894
  default: return unknownTool(name);
3915
3895
  }
@@ -3919,6 +3899,46 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3919
3899
  });
3920
3900
  return server;
3921
3901
  }
3902
+ /**
3903
+ * Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or
3904
+ * `null` when the value is not one of the four accepted modes.
3905
+ */
3906
+ function normalizeStartDebugMode(raw) {
3907
+ if (raw === "local-browser-dev") return "local-browser-dev";
3908
+ if (raw === "local-browser-cdp") return "local-browser-cdp";
3909
+ if (raw === "relay-dev") return "relay-dev";
3910
+ if (raw === "relay-live") return "relay-live";
3911
+ return null;
3912
+ }
3913
+ /**
3914
+ * Builds a trivial `ConnectionRouter` pinned to a single connection (issue
3915
+ * #348). Used by `createDebugServer` when no real dual router is injected —
3916
+ * every existing single-connection test and the `local`-only / `relay`-only
3917
+ * boot path. `switchMode` here cannot lazily boot another family, so it only
3918
+ * honors a request that matches the connection's own kind (and arms/disarms
3919
+ * `liveIntent` accordingly for relay-live); any cross-family request is
3920
+ * rejected with a clear "dynamic switch unavailable in this session" error.
3921
+ */
3922
+ function makeSingleConnectionRouter(connection) {
3923
+ return {
3924
+ get active() {
3925
+ return connection;
3926
+ },
3927
+ switchMode(mode, confirm) {
3928
+ if (isRelayMode(mode) !== (connection.kind === "relay")) return Promise.reject(/* @__PURE__ */ new Error(`start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — '${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 원하는 모드로 재시작하세요.`));
3929
+ if (mode === "relay-live" && !confirm) return Promise.reject(/* @__PURE__ */ new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요."));
3930
+ setLiveIntent(mode === "relay-live");
3931
+ const environment = deriveEnvironment(connection.kind, getLiveIntent());
3932
+ return Promise.resolve({
3933
+ mode,
3934
+ environment,
3935
+ kind: connection.kind,
3936
+ liveGuardActive: connection.kind === "relay" && getLiveIntent(),
3937
+ nextStep: connection.kind === "relay" ? "build_attach_url로 attach QR을 생성하세요." : "list_pages로 로컬 페이지 attach를 확인하세요."
3938
+ });
3939
+ }
3940
+ };
3941
+ }
3922
3942
  function jsonResult$1(value) {
3923
3943
  return { content: [{
3924
3944
  type: "text",
@@ -4080,21 +4100,72 @@ function createRelayConnection(relayBaseUrl) {
4080
4100
  return new ChiiCdpConnection({ relayBaseUrl });
4081
4101
  }
4082
4102
  /**
4083
- * Boots the live debug stack and serves it over stdio:
4084
- * 1. start the Chii relay on an OS-assigned port (with TOTP auth if
4085
- * AIT_DEBUG_TOTP_SECRET is set),
4086
- * 2. open a cloudflared quick tunnel to the relay's confirmed port,
4087
- * 3. print relay URL + attach instructions,
4088
- * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
4103
+ * AIT source that always forwards over the *currently active* connection
4104
+ * (issue #348). The single-connection `ChiiAitSource` binds one sender at
4105
+ * construction; in the dual-connection daemon the AIT.* domain must follow the
4106
+ * active connection across `start_debug` swaps, so this indirection reads
4107
+ * `getActive()` on every call.
4108
+ *
4109
+ * Both `ChiiCdpConnection` and `LocalCdpConnection` expose `sendCommand`, so
4110
+ * the active connection is a valid `AitCommandSender`.
4089
4111
  */
4090
- async function runDebugServer(options = {}) {
4091
- const lockHandle = acquireLock({ force: options.force ?? false });
4112
+ var RoutingAitSource = class extends ChiiAitSource {
4113
+ constructor(getActive) {
4114
+ super({ sendCommand: (method, params) => getActive().sendCommand(method, params) });
4115
+ }
4116
+ };
4117
+ /**
4118
+ * Boots the local-browser family (issues #348, #356). Launches a Chromium with
4119
+ * `--remote-debugging-port` and returns a `LocalCdpConnection` attached to it,
4120
+ * plus a `stop()` that kills both.
4121
+ *
4122
+ * Used two ways:
4123
+ * - `runDebugServer` (relay-eager): the dual router's lazy callback, booted at
4124
+ * most once on the first `start_debug({ mode: 'local-*' })`.
4125
+ * - `runLocalDebugServer` (local-eager, #356): the eager family booted at
4126
+ * startup.
4127
+ */
4128
+ async function bootLocalFamily() {
4129
+ const chromium = await launchChromium({
4130
+ port: 0,
4131
+ devUrl: process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
4132
+ });
4133
+ await new Promise((r) => setTimeout(r, 800));
4134
+ const connection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
4135
+ return {
4136
+ connection,
4137
+ stop() {
4138
+ connection.close();
4139
+ chromium.stop();
4140
+ }
4141
+ };
4142
+ }
4143
+ /**
4144
+ * Boots the relay family (issues #348, #356): starts the Chii relay on an
4145
+ * OS-assigned port (with optional TOTP gate), opens a cloudflared quick tunnel
4146
+ * to the relay's confirmed port in the background, prints the attach banner,
4147
+ * and arms the tunnel health probe. Returns a {@link BootedFamily} whose
4148
+ * `getTunnelStatus()` reflects the live tunnel (it flips up once the background
4149
+ * tunnel resolves and follows reissues).
4150
+ *
4151
+ * Used two ways (symmetry with {@link bootLocalFamily}):
4152
+ * - `runDebugServer` (relay-eager): booted at startup.
4153
+ * - `runLocalDebugServer` (local-eager, #356): the dual router's lazy
4154
+ * callback, booted at most once on the first `start_debug({ mode: 'relay-*' })`.
4155
+ *
4156
+ * The relay base URL is only known after `startChiiRelay()` resolves, so the
4157
+ * `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside
4158
+ * this function, after the relay port is confirmed.
4159
+ *
4160
+ * SECRET-HANDLING: the TOTP secret rides only inside `verifyAuth`; the wssUrl
4161
+ * (relay host) is never logged here directly.
4162
+ */
4163
+ async function bootRelayFamily(options = {}) {
4092
4164
  const relayPort = options.relayPort ?? 0;
4093
- const verifyAuth = buildRelayVerifyAuth();
4094
- const totpEnabled = verifyAuth !== void 0;
4165
+ const totpEnabled = options.verifyAuth !== void 0;
4095
4166
  const relay = await startChiiRelay({
4096
4167
  port: relayPort,
4097
- verifyAuth
4168
+ verifyAuth: options.verifyAuth
4098
4169
  });
4099
4170
  logInfo("server.start", {
4100
4171
  port: relay.port,
@@ -4102,18 +4173,18 @@ async function runDebugServer(options = {}) {
4102
4173
  });
4103
4174
  let tunnel = null;
4104
4175
  let tunnelStatus = makeTunnelStatus(false, null);
4105
- generateAttachToken();
4106
4176
  let tunnelProbe = null;
4177
+ generateAttachToken();
4107
4178
  startQuickTunnel(relay.port).then((t) => {
4108
4179
  tunnel = t;
4109
4180
  tunnelStatus = makeTunnelStatus(true, t.wssUrl);
4110
- lockHandle.updateWssUrl(t.wssUrl);
4181
+ options.onWssUrl?.(t.wssUrl);
4111
4182
  logInfo("tunnel.up", { totpEnabled });
4112
4183
  tunnelProbe = startTunnelHealthProbe(t, relay.port, {
4113
4184
  onReissue: (newTunnel) => {
4114
4185
  tunnel = newTunnel;
4115
4186
  tunnelStatus = makeTunnelStatus(true, newTunnel.wssUrl, null, 0);
4116
- lockHandle.updateWssUrl(newTunnel.wssUrl);
4187
+ options.onWssUrl?.(newTunnel.wssUrl);
4117
4188
  printAttachBanner({
4118
4189
  wssUrl: newTunnel.wssUrl,
4119
4190
  totpEnabled
@@ -4137,39 +4208,178 @@ async function runDebugServer(options = {}) {
4137
4208
  logError("tunnel.down", { msg: `Failed to open cloudflared quick tunnel: ${err instanceof Error ? err.message : String(err)}. The relay is up locally; attach over the public URL is unavailable until the tunnel starts.` });
4138
4209
  });
4139
4210
  const connection = createRelayConnection(relay.baseUrl);
4140
- const aitSource = new ChiiAitSource(connection);
4211
+ return {
4212
+ connection,
4213
+ getTunnelStatus: () => tunnelStatus,
4214
+ stop() {
4215
+ tunnelProbe?.stop();
4216
+ tunnel?.stop();
4217
+ connection.close();
4218
+ relay.close();
4219
+ }
4220
+ };
4221
+ }
4222
+ /**
4223
+ * Production `ConnectionRouter` (issues #348, #356 — DUAL-CONNECTION-COEXIST).
4224
+ *
4225
+ * Holds two families — one booted eagerly at startup, the other lazily on the
4226
+ * first cross-family switch — an `active` pointer, and the single attach watcher
4227
+ * armed on the active connection. The router is **direction-neutral** (#356):
4228
+ * either family kind can be the eager one, so a `--target=local` session can
4229
+ * hot-switch into relay (and vice versa) without restarting the MCP server.
4230
+ *
4231
+ * `switchMode`:
4232
+ * 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed relay-live;
4233
+ * 2. routes by the requested mode's family kind: same kind as `eager` → reuse
4234
+ * eager; different kind → lazily boot (once) and keep warm;
4235
+ * 3. flips `active` (the MCP `Server` never re-handshakes — it reads through
4236
+ * `active` per request);
4237
+ * 4. sets `liveIntent` (true only for relay-live);
4238
+ * 5. stops the old attach watcher and re-arms one on the new connection
4239
+ * (the watcher self-clears, so re-arm is mandatory);
4240
+ * 6. emits `tools/list_changed`.
4241
+ *
4242
+ * Inactive infra is left WARM — teardown happens only at process exit (the
4243
+ * unified shutdown in the run functions), which is what keeps a phone attach
4244
+ * alive across a local→relay→local round trip.
4245
+ */
4246
+ var DualConnectionRouter = class {
4247
+ deps;
4248
+ /** The opposite-kind family, booted lazily on the first cross-family switch. */
4249
+ lazyFamily = null;
4250
+ activeFamily;
4251
+ server = null;
4252
+ attachWatcher = null;
4253
+ swapInFlight = false;
4254
+ constructor(deps) {
4255
+ this.deps = deps;
4256
+ this.activeFamily = deps.eager;
4257
+ }
4258
+ get active() {
4259
+ return this.activeFamily.connection;
4260
+ }
4261
+ /** Every booted family (for unified shutdown). */
4262
+ bootedFamilies() {
4263
+ return this.lazyFamily ? [this.deps.eager, this.lazyFamily] : [this.deps.eager];
4264
+ }
4265
+ /**
4266
+ * Live tunnel status of the relay family, regardless of whether relay is the
4267
+ * eager or lazy family (#356). Returns "down" until the relay family has been
4268
+ * booted (local-eager sessions before the first relay switch) — which is the
4269
+ * correct signal for `build_attach_url` (no tunnel exists yet).
4270
+ */
4271
+ relayTunnelStatus() {
4272
+ for (const family of this.bootedFamilies()) if (family.getTunnelStatus) return family.getTunnelStatus();
4273
+ return {
4274
+ up: false,
4275
+ wssUrl: null
4276
+ };
4277
+ }
4278
+ /**
4279
+ * Binds the MCP `Server` and arms the initial attach watcher on the active
4280
+ * connection. Called once after `createDebugServer` + `connect`.
4281
+ */
4282
+ start(server) {
4283
+ this.server = server;
4284
+ this.armWatcher();
4285
+ }
4286
+ /** Stops the current attach watcher (for shutdown). */
4287
+ stopWatcher() {
4288
+ this.attachWatcher?.stop();
4289
+ this.attachWatcher = null;
4290
+ }
4291
+ /** Arms a fresh attach watcher on the current active connection. */
4292
+ armWatcher() {
4293
+ const server = this.server;
4294
+ if (!server) return;
4295
+ this.attachWatcher = startAttachWatcher(this.activeFamily.connection, server, this.deps.attachWatcherIntervalMs ?? 1e3, () => {
4296
+ this.deps.diagnosticsCollector.recordAttach();
4297
+ if (this.activeFamily.connection.kind === "relay") this.deps.devtoolsOpener.open(this.relayTunnelStatus().wssUrl, deriveEnvironment(this.activeFamily.connection.kind, getLiveIntent()));
4298
+ });
4299
+ }
4300
+ async switchMode(mode, confirm) {
4301
+ if (this.swapInFlight) throw new Error("start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.");
4302
+ if (mode === "relay-live" && !confirm) throw new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요.");
4303
+ this.swapInFlight = true;
4304
+ try {
4305
+ const wantRelay = isRelayMode(mode);
4306
+ const wantKind = wantRelay ? "relay" : "local";
4307
+ let target;
4308
+ if (wantKind === this.deps.eager.connection.kind) target = this.deps.eager;
4309
+ else {
4310
+ if (this.lazyFamily === null) this.lazyFamily = await this.deps.bootLazy();
4311
+ target = this.lazyFamily;
4312
+ }
4313
+ this.activeFamily = target;
4314
+ setLiveIntent(mode === "relay-live");
4315
+ this.stopWatcher();
4316
+ this.armWatcher();
4317
+ this.server?.sendToolListChanged();
4318
+ return {
4319
+ mode,
4320
+ environment: deriveEnvironment(target.connection.kind, getLiveIntent()),
4321
+ kind: target.connection.kind,
4322
+ liveGuardActive: target.connection.kind === "relay" && getLiveIntent(),
4323
+ nextStep: wantRelay ? "build_attach_url로 attach QR을 생성하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
4324
+ };
4325
+ } finally {
4326
+ this.swapInFlight = false;
4327
+ }
4328
+ }
4329
+ };
4330
+ /**
4331
+ * Boots the live debug stack and serves it over stdio:
4332
+ * 1. start the Chii relay on an OS-assigned port (with TOTP auth if
4333
+ * AIT_DEBUG_TOTP_SECRET is set),
4334
+ * 2. open a cloudflared quick tunnel to the relay's confirmed port,
4335
+ * 3. print relay URL + attach instructions,
4336
+ * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
4337
+ */
4338
+ async function runDebugServer(options = {}) {
4339
+ const lockHandle = acquireLock({ force: options.force ?? false });
4340
+ const verifyAuth = buildRelayVerifyAuth();
4341
+ const relayFamily = await bootRelayFamily({
4342
+ relayPort: options.relayPort,
4343
+ verifyAuth,
4344
+ onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
4345
+ });
4346
+ const devtoolsOpener = new AutoDevtoolsOpener();
4347
+ const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4348
+ const router = new DualConnectionRouter({
4349
+ eager: relayFamily,
4350
+ bootLazy: bootLocalFamily,
4351
+ diagnosticsCollector,
4352
+ devtoolsOpener
4353
+ });
4354
+ const aitSource = new RoutingAitSource(() => {
4355
+ return router.active;
4356
+ });
4141
4357
  let qrServer;
4142
4358
  try {
4143
4359
  qrServer = await startQrHttpServer();
4144
4360
  } catch (err) {
4145
4361
  logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
4146
4362
  }
4147
- const devtoolsOpener = new AutoDevtoolsOpener();
4148
- const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4149
4363
  const server = createDebugServer({
4150
- connection,
4364
+ connection: router.active,
4365
+ router,
4151
4366
  aitSource,
4152
- getTunnelStatus: () => tunnelStatus,
4367
+ getTunnelStatus: () => router.relayTunnelStatus(),
4153
4368
  get qrHttpServer() {
4154
4369
  return qrServer;
4155
4370
  },
4156
4371
  diagnosticsCollector,
4157
- defaultEnv: "relay-dev",
4158
4372
  ...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
4159
4373
  });
4160
4374
  const transport = new StdioServerTransport();
4161
4375
  let closed = false;
4162
- let attachWatcher = null;
4163
4376
  let parentWatcher = null;
4164
4377
  const shutdown = () => {
4165
4378
  if (closed) return;
4166
4379
  closed = true;
4167
4380
  parentWatcher?.stop();
4168
- attachWatcher?.stop();
4169
- tunnelProbe?.stop();
4170
- connection.close();
4171
- tunnel?.stop();
4172
- relay.close();
4381
+ router.stopWatcher();
4382
+ for (const family of router.bootedFamilies()) family.stop();
4173
4383
  server.close();
4174
4384
  qrServer?.close();
4175
4385
  lockHandle.release();
@@ -4181,9 +4391,8 @@ async function runDebugServer(options = {}) {
4181
4391
  if (!closed) {
4182
4392
  closed = true;
4183
4393
  parentWatcher?.stop();
4184
- attachWatcher?.stop();
4185
- tunnelProbe?.stop();
4186
- tunnel?.stop();
4394
+ router.stopWatcher();
4395
+ for (const family of router.bootedFamilies()) family.stop();
4187
4396
  lockHandle.release();
4188
4397
  }
4189
4398
  });
@@ -4204,13 +4413,7 @@ async function runDebugServer(options = {}) {
4204
4413
  process.exit(1);
4205
4414
  });
4206
4415
  await server.connect(transport);
4207
- attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
4208
- diagnosticsCollector.recordAttach();
4209
- devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({
4210
- connection,
4211
- defaultEnv: "relay-dev"
4212
- }));
4213
- });
4416
+ router.start(server);
4214
4417
  if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
4215
4418
  parentWatcher = startParentWatcher(() => {
4216
4419
  shutdown();
@@ -4230,19 +4433,28 @@ async function runDebugServer(options = {}) {
4230
4433
  * Boots the local-browser debug stack and serves it over stdio:
4231
4434
  * 1. launch a local Chromium with `--remote-debugging-port=<port>`,
4232
4435
  * 2. attach a `LocalCdpConnection` to the first non-blank page target,
4233
- * 3. expose the debug tools backed by that connection + a `ChiiAitSource`.
4234
- *
4235
- * `build_attach_url` (relay-specific, generates a deep-link + QR for the phone)
4236
- * is not applicable in local mode because there is no relay or tunnel. The tool
4237
- * is still listed (it is part of `DEBUG_TOOL_DEFINITIONS`) but will return a
4238
- * clear "not applicable" message via the tunnel-down path (wssUrl is null).
4436
+ * 3. expose the debug tools through the SAME direction-neutral
4437
+ * `DualConnectionRouter` that `runDebugServer` uses (issue #356) — the
4438
+ * local family is eager, the relay family is lazy-booted on the first
4439
+ * `start_debug({ mode: 'relay-*' })`.
4440
+ *
4441
+ * Symmetry with `runDebugServer` (#356): starting with `--target=local` no
4442
+ * longer pins a single-connection router. A `--target=local` session can
4443
+ * hot-switch into relay (env 1 → env 3) without restarting the MCP server,
4444
+ * closing the asymmetry where only the default (relay-target) entry point had
4445
+ * bidirectional hot-switch. The intended fidelity-ladder flow — "validate in
4446
+ * env 1 (local), then env 3 (intoss-private) in ONE session, no restart" — now
4447
+ * works from either entry point.
4448
+ *
4449
+ * `build_attach_url` (relay-specific) stays effectively hidden / non-applicable
4450
+ * until the relay family is booted: before the first relay switch the env
4451
+ * derives to `mock` and `relayTunnelStatus()` reports "down", so the tool fails
4452
+ * with a clear "tunnel not up" message. After a relay switch the relay tunnel
4453
+ * is live and the tool works.
4239
4454
  *
4240
4455
  * The AIT.* tools (`AIT.getSdkCallHistory`, `AIT.getMockState`,
4241
- * `AIT.getOperationalEnvironment`) ride the same CDP channel via
4242
- * `ChiiAitSource` `LocalCdpConnection.sendCommand`. They will succeed once
4243
- * the sdk-example dev-bridge (`window.__sdkCall` install) lands in sdk-example;
4244
- * until then they return the sdk-example "bridge absent" message — which is
4245
- * expected and noted in the PR as an explicit out-of-scope follow-up.
4456
+ * `AIT.getOperationalEnvironment`) ride the *active* connection's CDP channel
4457
+ * via `RoutingAitSource`, so they follow `start_debug` swaps.
4246
4458
  */
4247
4459
  async function runLocalDebugServer(options = {}) {
4248
4460
  const lockHandle = acquireLock({ force: options.force ?? false });
@@ -4251,30 +4463,57 @@ async function runLocalDebugServer(options = {}) {
4251
4463
  devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
4252
4464
  });
4253
4465
  await new Promise((r) => setTimeout(r, 800));
4254
- const connection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
4255
- const aitSource = new ChiiAitSource(connection);
4256
- const tunnelStatus = {
4257
- up: false,
4258
- wssUrl: null
4466
+ const localConnection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
4467
+ const localFamily = {
4468
+ connection: localConnection,
4469
+ stop() {
4470
+ localConnection.close();
4471
+ chromium.stop();
4472
+ }
4259
4473
  };
4474
+ const verifyAuth = buildRelayVerifyAuth();
4475
+ const devtoolsOpener = new AutoDevtoolsOpener();
4476
+ const diagnosticsCollector = new InMemoryDiagnosticsCollector();
4477
+ const router = new DualConnectionRouter({
4478
+ eager: localFamily,
4479
+ bootLazy: () => bootRelayFamily({
4480
+ verifyAuth,
4481
+ onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
4482
+ }),
4483
+ diagnosticsCollector,
4484
+ devtoolsOpener
4485
+ });
4486
+ const aitSource = new RoutingAitSource(() => {
4487
+ return router.active;
4488
+ });
4489
+ let qrServer;
4490
+ try {
4491
+ qrServer = await startQrHttpServer();
4492
+ } catch (err) {
4493
+ logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
4494
+ }
4260
4495
  const server = createDebugServer({
4261
- connection,
4496
+ connection: router.active,
4497
+ router,
4262
4498
  aitSource,
4263
- getTunnelStatus: () => tunnelStatus,
4264
- defaultEnv: "mock"
4499
+ getTunnelStatus: () => router.relayTunnelStatus(),
4500
+ get qrHttpServer() {
4501
+ return qrServer;
4502
+ },
4503
+ diagnosticsCollector,
4504
+ ...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
4265
4505
  });
4266
4506
  const transport = new StdioServerTransport();
4267
4507
  let closed = false;
4268
- let attachWatcher = null;
4269
4508
  let parentWatcher = null;
4270
4509
  const shutdown = () => {
4271
4510
  if (closed) return;
4272
4511
  closed = true;
4273
4512
  parentWatcher?.stop();
4274
- attachWatcher?.stop();
4275
- connection.close();
4276
- chromium.stop();
4513
+ router.stopWatcher();
4514
+ for (const family of router.bootedFamilies()) family.stop();
4277
4515
  server.close();
4516
+ qrServer?.close();
4278
4517
  lockHandle.release();
4279
4518
  };
4280
4519
  process.once("SIGINT", shutdown);
@@ -4284,8 +4523,8 @@ async function runLocalDebugServer(options = {}) {
4284
4523
  if (!closed) {
4285
4524
  closed = true;
4286
4525
  parentWatcher?.stop();
4287
- attachWatcher?.stop();
4288
- chromium.stop();
4526
+ router.stopWatcher();
4527
+ for (const family of router.bootedFamilies()) family.stop();
4289
4528
  lockHandle.release();
4290
4529
  }
4291
4530
  });
@@ -4308,7 +4547,7 @@ async function runLocalDebugServer(options = {}) {
4308
4547
  process.exit(1);
4309
4548
  });
4310
4549
  await server.connect(transport);
4311
- attachWatcher = startAttachWatcher(connection, server);
4550
+ router.start(server);
4312
4551
  if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
4313
4552
  parentWatcher = startParentWatcher(() => {
4314
4553
  shutdown();
@@ -4752,7 +4991,7 @@ function createDevServer(deps = {}) {
4752
4991
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
4753
4992
  const server = new Server({
4754
4993
  name: "ait-devtools",
4755
- version: "0.1.51"
4994
+ version: "0.1.52"
4756
4995
  }, { capabilities: { tools: {} } });
4757
4996
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
4758
4997
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -4836,9 +5075,29 @@ async function runDevServer() {
4836
5075
  * --mode=dev — dev mode — reads the live browser mock state from a running
4837
5076
  * Vite dev server (the devtools#130 `devtools_get_mock_state` surface).
4838
5077
  *
5078
+ * Back-compat (issue #348): the legacy `--mode`/`--target` flags and `MCP_ENV`
5079
+ * still work. `--target=relay`/`local` select the initial active connection;
5080
+ * the in-session `start_debug(mode)` MCP tool can then flip between them with no
5081
+ * restart. `MCP_ENV=relay-live` seeds LIVE intent at boot (deprecated alias);
5082
+ * `MCP_ENV=mock|relay|relay-dev` are accepted and ignored for env derivation
5083
+ * (the active connection's `kind` is authoritative).
5084
+ *
4839
5085
  * Node-only stdio process.
4840
5086
  */
4841
5087
  /**
5088
+ * Seeds the module-level `liveIntent` bit from the deprecated `MCP_ENV` alias
5089
+ * (issue #348). `MCP_ENV=relay-live` is the only value that matters now — it
5090
+ * arms LIVE intent at boot so a session launched straight into env 4 has the
5091
+ * guard active without a `start_debug({ mode: 'relay-live' })` round-trip. All
5092
+ * other `MCP_ENV` values (`mock`, `relay`, `relay-dev`) are accepted-and-ignored
5093
+ * for env derivation — the active connection's `kind` is authoritative.
5094
+ *
5095
+ * SECRET-HANDLING: reads only the env-var string; never logs a secret.
5096
+ */
5097
+ function seedLiveIntentFromEnv(env = process.env) {
5098
+ if (env.MCP_ENV === "relay-live") setLiveIntent(true);
5099
+ }
5100
+ /**
4842
5101
  * Returns `true` when `--force` or `--takeover` is present in argv.
4843
5102
  *
4844
5103
  * Both flags are accepted as aliases — `--force` is the short form listed in
@@ -4893,6 +5152,7 @@ function normalizeTarget(value) {
4893
5152
  }
4894
5153
  async function main() {
4895
5154
  const args = process.argv.slice(2);
5155
+ seedLiveIntentFromEnv();
4896
5156
  if (parseMode(args) === "dev") await runDevServer();
4897
5157
  else {
4898
5158
  const target = parseTarget(args);
@@ -4926,6 +5186,6 @@ if (isEntrypoint()) main().catch((err) => {
4926
5186
  process.exitCode = 1;
4927
5187
  });
4928
5188
  //#endregion
4929
- export { parseForce, parseMode, parseTarget };
5189
+ export { parseForce, parseMode, parseTarget, seedLiveIntentFromEnv };
4930
5190
 
4931
5191
  //# sourceMappingURL=cli.js.map