triflux 10.35.2 → 10.36.0

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/bin/triflux.mjs CHANGED
@@ -75,6 +75,7 @@ import {
75
75
  formatPsmuxUpdateGuidance,
76
76
  probePsmuxSupport,
77
77
  } from "../scripts/lib/psmux-info.mjs";
78
+ import { main as stealthFetchMain } from "../scripts/lib/stealth-fetch.mjs";
78
79
  import {
79
80
  buildWindowsHubAutostartCommand,
80
81
  cleanupStaleSkills,
@@ -189,6 +190,11 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
189
190
  },
190
191
  ],
191
192
  },
193
+ "stealth-fetch": {
194
+ usage: "tfx stealth-fetch <url>",
195
+ description:
196
+ "cloakbrowser 기반 단일 URL fetch (http/https only, JSON stdout)",
197
+ },
192
198
  doctor: {
193
199
  usage:
194
200
  "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--cleanup-stale-tmux --prefix tfx-* --age-min N --dry-run|--apply] [--json]",
@@ -6327,6 +6333,7 @@ ${updateNotice}
6327
6333
  ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
6328
6334
  ${DIM} --json${RESET} ${GRAY}구조화된 진단 결과 JSON 출력${RESET}
6329
6335
  ${WHITE_BRIGHT}tfx auto${RESET} ${GRAY}tfx-auto 라우팅 결정 미리보기 (--cli codex|antigravity|claude)${RESET}
6336
+ ${WHITE_BRIGHT}tfx stealth-fetch${RESET} ${GRAY}cloakbrowser 기반 URL fetch (JSON stdout)${RESET}
6330
6337
  ${WHITE_BRIGHT}tfx mcp${RESET} ${GRAY}MCP registry 관리 (list/sync/add/remove)${RESET}
6331
6338
  ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
6332
6339
  ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
@@ -7281,6 +7288,13 @@ async function main() {
7281
7288
  enableHubAutostart: cmdArgs.includes("--enable-hub-autostart"),
7282
7289
  });
7283
7290
  return;
7291
+ case "stealth-fetch":
7292
+ if (cmdArgs.some(isHelpArg)) {
7293
+ printCommandHelp("stealth-fetch");
7294
+ return;
7295
+ }
7296
+ await stealthFetchMain([process.argv[0], "stealth-fetch", ...cmdArgs]);
7297
+ return;
7284
7298
  case "doctor": {
7285
7299
  if (cmdArgs.some(isHelpArg)) {
7286
7300
  printCommandHelp("doctor");
package/cto/collect.mjs CHANGED
@@ -17,6 +17,7 @@ import { basename, dirname, join, relative } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
19
  import { renderBrief } from "./brief.mjs";
20
+ import { resolveLakeRootDir } from "./lake-root.mjs";
20
21
 
21
22
  const SCHEMA_VERSION = "cto-lake.v1";
22
23
  const SOURCE_REGISTRY = [
@@ -785,7 +786,7 @@ function hasFlag(args, flag) {
785
786
  }
786
787
 
787
788
  export async function runCollect(args = [], opts = {}) {
788
- const rootDir = opts.rootDir || process.cwd();
789
+ const rootDir = opts.rootDir || resolveLakeRootDir(process.cwd());
789
790
  const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
790
791
  const stdout = opts.stdout || process.stdout;
791
792
  const stderr = opts.stderr || process.stderr;
package/cto/dashboard.mjs CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  } from "node:fs";
8
8
  import { basename, dirname, join } from "node:path";
9
9
 
10
+ import { resolveLakeRootDir } from "./lake-root.mjs";
11
+
10
12
  const DEFAULT_INTERVAL_MS = 60_000;
11
13
 
12
14
  function hasFlag(args, flag) {
@@ -360,7 +362,7 @@ async function renderOnce({ lakeRoot, stdout, stdoutHtml, renders }) {
360
362
  }
361
363
 
362
364
  export async function runDashboard(args = [], opts = {}) {
363
- const rootDir = opts.rootDir || process.cwd();
365
+ const rootDir = opts.rootDir || resolveLakeRootDir(process.cwd());
364
366
  const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
365
367
  const stdout = opts.stdout || process.stdout;
366
368
  const watch = opts.watch === true || hasFlag(args, "--watch");
@@ -0,0 +1,44 @@
1
+ // cto/lake-root.mjs - resolve the git repo toplevel for the CTO lake.
2
+ //
3
+ // CTO lake (.triflux/lake/current.json) 는 repo 루트에 하나만 둔다. 그런데
4
+ // runStatus/runCollect/runDashboard 는 process.cwd() 를 기본 rootDir 로 쓰므로,
5
+ // repo 루트가 아닌 하위 폴더(예: packages/triflux)에서 `tfx cto` 를 부르면 lake
6
+ // 를 못 찾아 "run tfx cto collect" 가 반복되고, collect 는 엉뚱한 하위 폴더에
7
+ // 새 .triflux/lake 를 만든다. cwd 에서 `.git` 마커를 위로 탐색해 toplevel 로
8
+ // 올려 이 불일치를 없앤다.
9
+ //
10
+ // git worktree 는 자체 `.git` 파일을 가지므로 worktree 루트에서 멈춘다 — lake
11
+ // 격리(워크트리별 collect)가 그대로 유지된다. `.git` 을 못 찾으면 cwd 를 그대로
12
+ // 반환해 기존 동작을 보존한다.
13
+
14
+ import { existsSync as defaultExistsSync } from "node:fs";
15
+ import { dirname, join, parse } from "node:path";
16
+
17
+ const MAX_DEPTH = 64;
18
+
19
+ /**
20
+ * cwd 에서 가장 가까운 git toplevel(`.git` 디렉토리 또는 파일이 있는 폴더)을
21
+ * 찾아 반환한다. 못 찾으면 cwd 를 그대로 돌려준다(기존 동작 보존).
22
+ *
23
+ * @param {string} cwd 시작 디렉토리(보통 process.cwd())
24
+ * @param {object} [opts]
25
+ * @param {(path: string) => boolean} [opts.existsSync] 테스트용 주입 seam
26
+ * @returns {string}
27
+ */
28
+ export function resolveLakeRootDir(cwd, opts = {}) {
29
+ const exists = opts?.existsSync || defaultExistsSync;
30
+ // 비문자열(객체/숫자 등 truthy 포함) 또는 빈 문자열이면 항상 "" 를 반환해
31
+ // @returns {string} 계약을 지킨다 — truthy 비문자열을 그대로 누설하지 않는다.
32
+ if (typeof cwd !== "string" || !cwd) return "";
33
+
34
+ let dir = cwd;
35
+ const { root } = parse(dir);
36
+ for (let depth = 0; depth < MAX_DEPTH; depth++) {
37
+ if (exists(join(dir, ".git"))) return dir;
38
+ if (dir === root) break;
39
+ const parent = dirname(dir);
40
+ if (parent === dir) break;
41
+ dir = parent;
42
+ }
43
+ return cwd;
44
+ }
package/cto/status.mjs CHANGED
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
+ import { resolveLakeRootDir } from "./lake-root.mjs";
6
+
5
7
  const SCHEMA_VERSION = "cto-lake.v1";
6
8
  const SYNAPSE_TIMEOUT_MS = 1500;
7
9
 
@@ -304,7 +306,7 @@ function renderHumanStatus(status) {
304
306
  }
305
307
 
306
308
  export async function runStatus(args = [], opts = {}) {
307
- const rootDir = opts.rootDir || process.cwd();
309
+ const rootDir = opts.rootDir || resolveLakeRootDir(process.cwd());
308
310
  const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
309
311
  const stdout = opts.stdout || process.stdout;
310
312
  const jsonOut = opts.json === true || hasFlag(args, "--json");
@@ -1,6 +1,8 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
+ import { resolveLakeRootDir } from "../../cto/lake-root.mjs";
5
+
4
6
  const DEFAULT_DEBOUNCE_MS = 120_000;
5
7
  const OFF_VALUES = new Set(["0", "false", "off", "no"]);
6
8
 
@@ -45,7 +47,7 @@ export function createCtoAutoCollector(opts = {}) {
45
47
  const cwd = typeof session?.cwd === "string" ? session.cwd : "";
46
48
  const worktree =
47
49
  typeof session?.worktreePath === "string" ? session.worktreePath : "";
48
- const projectRoot = worktree || cwd;
50
+ const projectRoot = resolveLakeRootDir(worktree || cwd);
49
51
  if (!projectRoot)
50
52
  return { triggered: false, reason: "missing-project-root" };
51
53
 
@@ -61,11 +63,19 @@ export function createCtoAutoCollector(opts = {}) {
61
63
  { sessionId, err: String(error?.message || error) },
62
64
  "cto.auto_collect.query_failed",
63
65
  );
64
- return { triggered: false, reason: "query-failed" };
65
- }
66
- if (!Array.isArray(peers) || peers.length < 1) {
67
- return { triggered: false, reason: "no-peers" };
66
+ // peer 조회 실패는 collect 막지 않는다 — peer 정보는 이제 라벨 용도뿐이라
67
+ // registry 일시 장애에도 solo 로 진행해 collect 회복력을 유지한다.
68
+ peers = [];
68
69
  }
70
+ // 단일 세션(peer 0개)도 허용한다 — 사용자 선택. peer 유무와 무관하게
71
+ // 아래 debounce + fresh-lake 게이트가 collect 빈도를 제어한다.
72
+ //
73
+ // 주의: peerCount 는 best-effort 라벨이다. querySessions 는 raw
74
+ // cwd/worktreePath 로 매칭하지만 projectRoot 는 resolved git toplevel 이라,
75
+ // 같은 repo 의 다른 하위 폴더 세션은 peer 로 안 잡혀 triggered-solo 로 보고될
76
+ // 수 있다. 즉 라벨은 repo-scoped 가 아니라 exact-cwd-scoped 다 — 향후 peer
77
+ // 기반 로직을 이 라벨 위에 다시 올릴 때 주의한다.
78
+ const peerCount = Array.isArray(peers) ? peers.length : 0;
69
79
 
70
80
  const currentTime = now();
71
81
  const last = lastCollectMs.get(projectRoot) || 0;
@@ -92,9 +102,9 @@ export function createCtoAutoCollector(opts = {}) {
92
102
  inFlight.add(promise);
93
103
  return {
94
104
  triggered: true,
95
- reason: "triggered",
105
+ reason: peerCount > 0 ? "triggered" : "triggered-solo",
96
106
  projectRoot,
97
- peerCount: peers.length,
107
+ peerCount,
98
108
  };
99
109
  }
100
110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.35.2",
3
+ "version": "10.36.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,10 @@
23
23
  "@triflux/remote": "^10.25.0",
24
24
  "tree-kill": "^1.2.2"
25
25
  },
26
+ "optionalDependencies": {
27
+ "cloakbrowser": "^0.3.31",
28
+ "playwright-core": "^1.53.0"
29
+ },
26
30
  "files": [
27
31
  "bin",
28
32
  "tui",
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ // SSRF boundary: stealthFetch only accepts http: and https: URLs. It does not
3
+ // guard private IPs, metadata hosts such as 169.254.x.x, or localhost; callers
4
+ // must treat the URL as already inside their trust boundary.
5
+
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ export class CloakBrowserUnavailableError extends Error {}
9
+
10
+ const DEFAULT_TIMEOUT_MS = 30_000;
11
+ const DEFAULT_WAIT_UNTIL = "load";
12
+ const SUPPORTED_PLATFORMS = new Set([
13
+ "linux:x64",
14
+ "linux:arm64",
15
+ "darwin:x64",
16
+ "darwin:arm64",
17
+ "win32:x64",
18
+ ]);
19
+ const EXIT_CODES = {
20
+ not_installed: 3,
21
+ unsupported_platform: 4,
22
+ runtime_error: 5,
23
+ blocked_scheme: 6,
24
+ };
25
+
26
+ function isSupportedPlatform(platform, arch) {
27
+ return SUPPORTED_PLATFORMS.has(`${platform}:${arch}`);
28
+ }
29
+
30
+ function isModuleNotFound(error) {
31
+ return (
32
+ error?.code === "ERR_MODULE_NOT_FOUND" || error?.code === "MODULE_NOT_FOUND"
33
+ );
34
+ }
35
+
36
+ function htmlToText(html) {
37
+ return String(html || "")
38
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, " ")
39
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/giu, " ")
40
+ .replace(/<[^>]+>/gu, " ")
41
+ .replace(/&nbsp;/giu, " ")
42
+ .replace(/&amp;/giu, "&")
43
+ .replace(/&lt;/giu, "<")
44
+ .replace(/&gt;/giu, ">")
45
+ .replace(/&quot;/giu, '"')
46
+ .replace(/&#39;/gu, "'")
47
+ .replace(/\s+/gu, " ")
48
+ .trim();
49
+ }
50
+
51
+ function responseStatus(response) {
52
+ if (!response) return null;
53
+ if (typeof response.status === "function") return response.status();
54
+ return response.status ?? null;
55
+ }
56
+
57
+ function responseUrl(response, fallbackUrl) {
58
+ if (!response) return fallbackUrl;
59
+ if (typeof response.url === "function") return response.url();
60
+ return response.url ?? fallbackUrl;
61
+ }
62
+
63
+ function parseAllowedFetchUrl(url) {
64
+ let parsed;
65
+ try {
66
+ parsed = new URL(url);
67
+ } catch {
68
+ return { ok: false, scheme: null };
69
+ }
70
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
71
+ return { ok: false, scheme: parsed.protocol };
72
+ }
73
+ return { ok: true, href: parsed.href };
74
+ }
75
+
76
+ async function closeBrowser(browser) {
77
+ if (!browser || typeof browser.close !== "function") return;
78
+ try {
79
+ await browser.close();
80
+ } catch {
81
+ // best effort: fetch callers only need the primary result signal
82
+ }
83
+ }
84
+
85
+ export async function stealthFetch(url, opts = {}) {
86
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
87
+ const waitUntil = opts.waitUntil ?? DEFAULT_WAIT_UNTIL;
88
+ const loadCloakBrowser = opts._import ?? (() => import("cloakbrowser"));
89
+ const platform = opts._platform ?? process.platform;
90
+ const arch = opts._arch ?? process.arch;
91
+
92
+ if (!isSupportedPlatform(platform, arch)) {
93
+ return {
94
+ ok: false,
95
+ reason: "unsupported_platform",
96
+ platform,
97
+ arch,
98
+ };
99
+ }
100
+
101
+ const parsedUrl = parseAllowedFetchUrl(url);
102
+ if (!parsedUrl.ok) {
103
+ return {
104
+ ok: false,
105
+ reason: "blocked_scheme",
106
+ scheme: parsedUrl.scheme,
107
+ };
108
+ }
109
+
110
+ let cloakbrowser;
111
+ try {
112
+ cloakbrowser = await loadCloakBrowser();
113
+ } catch (error) {
114
+ if (isModuleNotFound(error)) return { ok: false, reason: "not_installed" };
115
+ return { ok: false, reason: "runtime_error", error: String(error) };
116
+ }
117
+
118
+ let browser;
119
+ try {
120
+ const launch = cloakbrowser?.launch;
121
+ if (typeof launch !== "function") {
122
+ throw new CloakBrowserUnavailableError("cloakbrowser launch unavailable");
123
+ }
124
+ browser = await launch();
125
+ const page = await browser.newPage();
126
+ const response = await page.goto(parsedUrl.href, {
127
+ waitUntil,
128
+ timeout: timeoutMs,
129
+ });
130
+ const html = await page.content();
131
+ return {
132
+ ok: true,
133
+ engine: "cloakbrowser",
134
+ url: parsedUrl.href,
135
+ finalUrl: responseUrl(response, parsedUrl.href),
136
+ status: responseStatus(response),
137
+ html,
138
+ text: htmlToText(html),
139
+ };
140
+ } catch (error) {
141
+ return { ok: false, reason: "runtime_error", error: String(error) };
142
+ } finally {
143
+ await closeBrowser(browser);
144
+ }
145
+ }
146
+
147
+ export function exitCodeFor(result) {
148
+ return EXIT_CODES[result?.reason] ?? 5;
149
+ }
150
+
151
+ export async function main(argv = process.argv) {
152
+ const url = argv[2];
153
+ if (!url) {
154
+ console.error("usage: stealth-fetch <url>");
155
+ process.exitCode = 2;
156
+ return;
157
+ }
158
+
159
+ const result = await stealthFetch(url);
160
+ if (result.ok) {
161
+ console.log(JSON.stringify(result));
162
+ process.exitCode = 0;
163
+ return;
164
+ }
165
+
166
+ console.error(`[stealth-fetch] 폴백: ${result.reason}`);
167
+ console.log(JSON.stringify(result));
168
+ process.exitCode = exitCodeFor(result);
169
+ }
170
+
171
+ if (
172
+ process.argv[1] &&
173
+ import.meta.url === pathToFileURL(process.argv[1]).href
174
+ ) {
175
+ await main();
176
+ }
package/scripts/setup.mjs CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  unlinkSync,
19
19
  writeFileSync,
20
20
  } from "fs";
21
+ import { createRequire } from "module";
21
22
  import { homedir } from "os";
22
23
  import { dirname, join, relative, resolve } from "path";
23
24
  import { fileURLToPath } from "url";
@@ -32,6 +33,7 @@ import { addPluginRootFallbackToCommand } from "./lib/doctor-env-checks.mjs";
32
33
  import { cleanupTmpFiles } from "./tmp-cleanup.mjs";
33
34
 
34
35
  const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
36
+ const require = createRequire(import.meta.url);
35
37
  // Windows 에서 os.homedir() 가 USERPROFILE 만 보고 process.env.HOME swap 을
36
38
  // 무시하기 때문에, integration test 가 fixture 격리한 spawn child 에서도
37
39
  // production ~/.codex/config.toml 을 건드릴 수 있다. (#193 회귀)
@@ -698,6 +700,90 @@ function isProtectedCodexConfigMutationEnv(env = process.env) {
698
700
  );
699
701
  }
700
702
 
703
+ function isProtectedCloakBrowserInstallEnv(env = process.env) {
704
+ return (
705
+ env.NODE_ENV === "test" ||
706
+ env.CI === "true" ||
707
+ env.TFX_TEST === "1" ||
708
+ Boolean(env.TRIFLUX_TEST_HOME) ||
709
+ Boolean(env.TEST_LOCK_PID) ||
710
+ Boolean(env.NODE_TEST_CONTEXT) ||
711
+ Boolean(env.NODE_TEST_WORKER_ID) ||
712
+ env.TFX_SKIP_CLOAKBROWSER_SETUP === "1"
713
+ );
714
+ }
715
+
716
+ function isCloakBrowserSupportedPlatform(platform, arch) {
717
+ return (
718
+ (platform === "linux" && (arch === "x64" || arch === "arm64")) ||
719
+ (platform === "darwin" && (arch === "x64" || arch === "arm64")) ||
720
+ (platform === "win32" && arch === "x64")
721
+ );
722
+ }
723
+
724
+ function ensureCloakBrowser({
725
+ env = process.env,
726
+ platform = process.platform,
727
+ arch = process.arch,
728
+ requireResolve = require.resolve,
729
+ execFileSyncFn = execFileSync,
730
+ warn = (message) => console.warn(message),
731
+ } = {}) {
732
+ if (isProtectedCloakBrowserInstallEnv(env)) {
733
+ return {
734
+ ok: true,
735
+ installed: false,
736
+ skipped: true,
737
+ reason: "protected-env",
738
+ };
739
+ }
740
+
741
+ if (!isCloakBrowserSupportedPlatform(platform, arch)) {
742
+ warn(
743
+ `[setup] cloakbrowser optional setup skipped: unsupported_platform (${platform}/${arch})`,
744
+ );
745
+ return {
746
+ ok: true,
747
+ installed: false,
748
+ skipped: true,
749
+ reason: "unsupported_platform",
750
+ };
751
+ }
752
+
753
+ try {
754
+ requireResolve("cloakbrowser", { paths: [PLUGIN_ROOT] });
755
+ return { ok: true, installed: true, skipped: false, reason: "installed" };
756
+ } catch {
757
+ // CloakBrowser downloads its browser binary lazily on first launch; there is
758
+ // no separate browser install command to run here.
759
+ }
760
+
761
+ try {
762
+ execFileSyncFn(
763
+ "npm",
764
+ ["install", "--no-save", "cloakbrowser", "playwright-core"],
765
+ {
766
+ cwd: PLUGIN_ROOT,
767
+ stdio: "ignore",
768
+ env,
769
+ timeout: 120_000,
770
+ },
771
+ );
772
+ return { ok: true, installed: true, skipped: false, reason: "installed" };
773
+ } catch (error) {
774
+ warn(
775
+ `[setup] cloakbrowser optional setup failed: ${_normalizeErrorMessage(error)}`,
776
+ );
777
+ return {
778
+ ok: false,
779
+ installed: false,
780
+ skipped: false,
781
+ reason: "install_failed",
782
+ error: _normalizeErrorMessage(error),
783
+ };
784
+ }
785
+ }
786
+
701
787
  /**
702
788
  * Codex config.json에 tfx-hub MCP 서버 엔트리를 보장한다.
703
789
  * @param {{ mcpUrl: string, createIfMissing?: boolean, enabled?: boolean }} opts
@@ -1404,6 +1490,7 @@ export {
1404
1490
  DEPRECATED_SKILLS,
1405
1491
  detectDevMode,
1406
1492
  ensureAgyHooks,
1493
+ ensureCloakBrowser,
1407
1494
  ensureCodexHooks,
1408
1495
  ensureCodexHubServerConfig,
1409
1496
  ensureCodexProfiles,
@@ -1490,6 +1577,15 @@ export async function runDeferred(stdinData) {
1490
1577
  result.action === "updated" ||
1491
1578
  result.action === "removed",
1492
1579
  ).length;
1580
+ const cloakBrowserResult = ensureCloakBrowser({
1581
+ warn: (message) => io.log(` \x1b[33m⚠\x1b[0m ${message}`),
1582
+ });
1583
+ if (
1584
+ cloakBrowserResult.reason === "installed" &&
1585
+ cloakBrowserResult.installed
1586
+ ) {
1587
+ io.log(" \x1b[32m✓\x1b[0m cloakbrowser optional backend ready");
1588
+ }
1493
1589
  if (pkgVersion && marker?.version === pkgVersion && !isForce) {
1494
1590
  if (claudeRoutingChangedCount > 0) {
1495
1591
  io.log(
@@ -123,6 +123,13 @@ AskUserQuestion:
123
123
  5. URL 콘텐츠 추출
124
124
  ```
125
125
 
126
+ ## URL 콘텐츠 수집 (stealth-first)
127
+
128
+ Quick 모드 Step 1.5 에서 `5. URL 콘텐츠 추출` 을 선택했거나 Deep 모드에서 소스 URL 본문이 필요하면:
129
+ 1. 기본 경로로 먼저 `node scripts/lib/stealth-fetch.mjs <url>` 를 실행한다.
130
+ 2. exit 0 이면 stdout JSON 의 `html` 또는 `text` 를 URL 본문 근거로 사용한다.
131
+ 3. exit 3/4/5 이면 stderr/stdout 의 reason 을 확인하고 `stealth-fetch 폴백: <이유>` 1줄 경고 후 기존 폴백을 사용한다: WebFetch → Tavily extract → Exa fetch.
132
+
126
133
  ### Step 2: Antigravity Google Search 위임
127
134
 
128
135
  ```