cuelo 0.6.0 → 0.6.1
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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +8 -8
- package/.next/build-manifest.json +2 -2
- package/.next/prerender-manifest.json +3 -3
- package/.next/required-server-files.js +2 -2
- package/.next/required-server-files.json +2 -2
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/api/settings/route.js +1 -1
- package/.next/server/app/api/updates/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +4 -4
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/page.js +3 -3
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/recover/page_client-reference-manifest.js +1 -1
- package/.next/server/app/recover.html +1 -1
- package/.next/server/app/recover.rsc +2 -2
- package/.next/server/app/recover.segments/_full.segment.rsc +2 -2
- package/.next/server/app/recover.segments/_head.segment.rsc +1 -1
- package/.next/server/app/recover.segments/_index.segment.rsc +2 -2
- package/.next/server/app/recover.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/recover.segments/recover/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/recover.segments/recover.segment.rsc +1 -1
- package/.next/server/app-paths-manifest.json +8 -8
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/app/{layout-31a0bfeae1eece12.js → layout-43a56002b0fb773b.js} +1 -1
- package/.next/static/chunks/app/{page-ba96ee458de51af9.js → page-952f237df95e7ee4.js} +2 -2
- package/.next/trace +104 -104
- package/.next/trace-build +1 -1
- package/README.md +3 -0
- package/Tools/CUELO_Setup/files/native-runtime-patch.js +5 -5
- package/Tools/OMP_Global_Config/agent/AGENTS.md +1 -1
- package/Tools/OMP_Global_Config/agent/RULES.md +1 -0
- package/Tools/OMP_Global_Config/agent/agents/maker.md +3 -3
- package/Tools/OMP_Global_Config/agent/extensions/character-voice.ts +33 -2
- package/Tools/OMP_Global_Config/agent/extensions/command-guard/index.ts +10 -0
- package/Tools/OMP_Global_Config/agent/extensions/lib/maker-routing.ts +4 -4
- package/Tools/OMP_Global_Config/agent/extensions/lib/mcp-selection.ts +359 -0
- package/Tools/OMP_Global_Config/agent/extensions/mcp-selection.ts +302 -0
- package/Tools/OMP_Global_Config/agent/prompt-copies/AGENTS.en.md +2 -2
- package/Tools/OMP_Global_Config/agent/prompt-copies/RULES.en.md +2 -1
- package/Tools/OMP_Global_Config/agent/rules/harness-policy.json +8 -8
- package/Tools/OMP_Global_Config/agent/rules/meta-harness.md +1 -1
- package/Tools/OMP_Global_Config/agent/rules/subagent.md +4 -4
- package/Tools/OMP_Global_Config/agent/sop/maker.md +2 -2
- package/Tools/OMP_Global_Config/patches/apply-core-patch.mjs +927 -0
- package/install.mjs +2 -2
- package/package.json +7 -7
- /package/.next/static/{zqQ2BdsFkwRQpfXpTFeIx → 9IHPWkHderLIQA0fYAPmt}/_buildManifest.js +0 -0
- /package/.next/static/{zqQ2BdsFkwRQpfXpTFeIx → 9IHPWkHderLIQA0fYAPmt}/_ssgManifest.js +0 -0
|
@@ -309,6 +309,30 @@ const EDITS = [
|
|
|
309
309
|
anchor: "\tasync settleAdmissions(): Promise<void> {",
|
|
310
310
|
patched: "\tfreezeForSteering(): void {\n\t\tthis.#steeringFreeze = true;\n\t}\n\n\tasync settleAdmissions(): Promise<void> {",
|
|
311
311
|
},
|
|
312
|
+
{
|
|
313
|
+
// 18.4.3 ADAPT: 새 `authorizeLaunch`(task speculative launch)가 위 steering freeze 를 보지 않아, hook 없는
|
|
314
|
+
// 세션에서는 user steering 이 큐에 들어간 뒤에도 subagent 조기 launch 가 시작될 수 있었다. 기존 freeze 의
|
|
315
|
+
// "큐잉 이후 아직 시작 안 한 speculative 작업은 시작하지 않는다" 를 이 진입 경로에도 적용한다.
|
|
316
|
+
// launch 가 거부되면 dispatch 가 평소대로 띄우므로 기능은 줄지 않는다.
|
|
317
|
+
file: "../pi-agent-core/src/speculative-execution.ts",
|
|
318
|
+
marker: "// HANSE: queued user steering freezes speculative launches",
|
|
319
|
+
anchor: ` async authorizeLaunch(context: SpeculativeLaunchContext): Promise<SpeculativeAuthorization> {
|
|
320
|
+
if (this.#closed) return { allowed: false, reason: "speculation coordinator is closed" };
|
|
321
|
+
`,
|
|
322
|
+
patched: ` async authorizeLaunch(context: SpeculativeLaunchContext): Promise<SpeculativeAuthorization> {
|
|
323
|
+
if (this.#closed) return { allowed: false, reason: "speculation coordinator is closed" };
|
|
324
|
+
// HANSE: queued user steering freezes speculative launches
|
|
325
|
+
if (this.#steeringFreeze) return { allowed: false, reason: "user steering is queued" };
|
|
326
|
+
`,
|
|
327
|
+
// 18.4.2 이하에는 authorizeLaunch 가 없다(launch 경로 자체가 없음). 18.4.3 이 import 사이에
|
|
328
|
+
// `SpeculativeLaunchContext` 를 넣기 전의 이웃 두 줄로 식별하고 아무것도 바꾸지 않는다.
|
|
329
|
+
alternates: [{
|
|
330
|
+
file: "../pi-agent-core/src/speculative-execution.ts",
|
|
331
|
+
marker: "\tSpeculativeCommitContext,\n\tSpeculativeOperationContext,\n",
|
|
332
|
+
anchor: "\tSpeculativeCommitContext,\n\tSpeculativeOperationContext,\n",
|
|
333
|
+
patched: "\tSpeculativeCommitContext,\n\tSpeculativeOperationContext,\n",
|
|
334
|
+
}],
|
|
335
|
+
},
|
|
312
336
|
{
|
|
313
337
|
file: "../pi-agent-core/src/agent.ts",
|
|
314
338
|
marker: "#userSteeringListeners = new Set<() => void>();",
|
|
@@ -2844,6 +2868,24 @@ import { isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-utils/fetch-retry";
|
|
|
2844
2868
|
}
|
|
2845
2869
|
}`,
|
|
2846
2870
|
},
|
|
2871
|
+
{
|
|
2872
|
+
// 시작 시 fallback 체인 검사는 조회 provider가 `idle`(캐시 없음)일 때만 "조회 중"으로 보고
|
|
2873
|
+
// 경고를 미룬다(#10048). 캐시 행이 있어도 모든 모델이 복원 불가 헤더로 걸러지면 상태는
|
|
2874
|
+
// `cached`인데 모델이 0개라, 곧 온라인 조회가 채울 모델을 unknown model로 경고한다.
|
|
2875
|
+
// 2026-09-29 실측: b-ai 57개 전부 header_omitted·unrestorable, 키는 agent.db에만 있어
|
|
2876
|
+
// models.yml로 헤더 복원 조건(authHeader+apiKey)을 만들 수 없다. 이 경우도 조회 중으로 본다.
|
|
2877
|
+
file: "src/config/model-registry.ts",
|
|
2878
|
+
marker: "// A cached row that restored no model is still pending",
|
|
2879
|
+
anchor: ` isProviderDiscoveryPending(provider: string): boolean {
|
|
2880
|
+
return this.#providerDiscoveryStates.get(provider)?.status === "idle";
|
|
2881
|
+
}`,
|
|
2882
|
+
patched: ` isProviderDiscoveryPending(provider: string): boolean {
|
|
2883
|
+
const state = this.#providerDiscoveryStates.get(provider);
|
|
2884
|
+
// A cached row that restored no model is still pending: every entry was
|
|
2885
|
+
// dropped for unrestorable headers, so only online discovery can supply it.
|
|
2886
|
+
return state?.status === "idle" || (state?.status === "cached" && state.models.length === 0);
|
|
2887
|
+
}`,
|
|
2888
|
+
},
|
|
2847
2889
|
// --- WEB6 요청의 현재 OMP 세션 귀속 ---
|
|
2848
2890
|
// OpenAI 호환 transport까지 내려온 StreamOptions.sessionId가 유일한 현재 세션 정본이다.
|
|
2849
2891
|
// prompt marker나 전역 시작 시각으로 복원하지 않고, WEB6 provider의 loopback 요청에만
|
|
@@ -4989,6 +5031,106 @@ export function hostNextServerEnvUnsets(
|
|
|
4989
5031
|
const commandEnv = buildNonInteractiveEnv(preflight.env);
|
|
4990
5032
|
`,
|
|
4991
5033
|
},
|
|
5034
|
+
{
|
|
5035
|
+
// 2026-09-29: 이름 있는 bash service 는 bash-executor 를 거치지 않는다. daemon broker 가
|
|
5036
|
+
// `workerEnvFromParent(spec.env)` 로 띄우는데, 이 함수는 broker 자신의 env 위에 overlay 를 덧씌우기만
|
|
5037
|
+
// 한다. CUELO 가 띄운 broker 는 Next 서버 env 를 물려받으므로, shell.env 에서 지운 서버 변수가 그대로
|
|
5038
|
+
// 되살아나 service 로 띄운 `next dev` 가 production 모드로 CSS 파싱에 실패했다(pipe·pty 모두 재현).
|
|
5039
|
+
// PTY 는 native 가 넘긴 env 를 다시 OS env 위에 덧씌우므로 map 에서 지우는 것으로는 막히지 않는다
|
|
5040
|
+
// (실측: map 삭제 leak, `delete process.env` 는 native 에도 반영). 그래서 broker 가 시작할 때 자기
|
|
5041
|
+
// env 에서 서버 변수를 한 번 지운다 — 바로 위 idle grace 변수를 지우는 자리다. 서버 변수는 broker
|
|
5042
|
+
// 자신이 아니라 CUELO 서버의 것이고, 호출자가 준 service env 와 런처 기준값(NODE_ENV·PORT)은
|
|
5043
|
+
// client 가 보내는 spec.env 에 있으므로 남는다. Next 서버 밖(NEXT_RUNTIME 없음)에서 뜬 broker 는 그대로다.
|
|
5044
|
+
file: "src/launch/broker.ts",
|
|
5045
|
+
marker: 'import { hostNextServerEnvUnsets } from "@oh-my-pi/pi-utils/env";',
|
|
5046
|
+
anchor: 'import { workerEnvFromParent } from "../subprocess/worker-client";\n',
|
|
5047
|
+
patched: 'import { workerEnvFromParent } from "../subprocess/worker-client";\nimport { hostNextServerEnvUnsets } from "@oh-my-pi/pi-utils/env";\n',
|
|
5048
|
+
},
|
|
5049
|
+
{
|
|
5050
|
+
file: "src/launch/broker.ts",
|
|
5051
|
+
marker: "// HANSE: broker env drops inherited CUELO next server env",
|
|
5052
|
+
anchor: "\tdelete process.env[DAEMON_IDLE_GRACE_ENV];\n",
|
|
5053
|
+
patched: ` delete process.env[DAEMON_IDLE_GRACE_ENV];
|
|
5054
|
+
// HANSE: broker env drops inherited CUELO next server env
|
|
5055
|
+
for (const name of hostNextServerEnvUnsets(process.env, {})) delete process.env[name];
|
|
5056
|
+
`,
|
|
5057
|
+
},
|
|
5058
|
+
{
|
|
5059
|
+
// 2026-09-29 jevgrep 비교(.omp/jevgrep-comparison/SUMMARY.md §4): jfind 의 비밀 파일 제외는 이름을
|
|
5060
|
+
// 대소문자 구분으로 비교해, Windows(NTFS 대소문자 무시)에서 같은 파일인 `Credentials.json`·`ID_RSA`
|
|
5061
|
+
// 가 검색·read·Jev 전송 후보에 들어갔다. secrets.json/yaml/yml 도 없었다. 이름을 한 번 소문자로 바꿔
|
|
5062
|
+
// 모든 비교에 쓰고 세 이름을 더한다. `.env.example` 등 템플릿 예외와 확장자 규칙은 그대로다.
|
|
5063
|
+
file: "src/tools/jfind/tree.ts",
|
|
5064
|
+
marker: '\t"secrets.json": true,',
|
|
5065
|
+
anchor: '\t"credentials.json": true,\n\t"client_secret.json": true,\n',
|
|
5066
|
+
patched: '\t"credentials.json": true,\n\t"secrets.json": true,\n\t"secrets.yaml": true,\n\t"secrets.yml": true,\n\t"client_secret.json": true,\n',
|
|
5067
|
+
},
|
|
5068
|
+
{
|
|
5069
|
+
file: "src/tools/jfind/tree.ts",
|
|
5070
|
+
marker: "// HANSE: secret names compare case-insensitively",
|
|
5071
|
+
anchor: `function secret(name: string): boolean {
|
|
5072
|
+
if (Object.hasOwn(SECRET_FILES, name)) return true;
|
|
5073
|
+
if (name.startsWith(".env.")) return !Object.hasOwn(ENV_TEMPLATES, name);
|
|
5074
|
+
return hasExt(name.toLowerCase(), SECRET_EXT);
|
|
5075
|
+
}`,
|
|
5076
|
+
patched: `function secret(name: string): boolean {
|
|
5077
|
+
// HANSE: secret names compare case-insensitively
|
|
5078
|
+
const lower = name.toLowerCase();
|
|
5079
|
+
if (Object.hasOwn(SECRET_FILES, lower)) return true;
|
|
5080
|
+
if (lower.startsWith(".env.")) return !Object.hasOwn(ENV_TEMPLATES, lower);
|
|
5081
|
+
return hasExt(lower, SECRET_EXT);
|
|
5082
|
+
}`,
|
|
5083
|
+
},
|
|
5084
|
+
// --- find 폴더 탐색 보완(B), search owner JevgrepComparison r2 proposal(.omp/jevgrep-comparison/find-hierarchy-edits.mjs) 그대로 ---
|
|
5085
|
+
{
|
|
5086
|
+
// find: hierarchical directory pass admits files past the lexical candidate cap (jevgrep comparison, .omp/jevgrep-comparison/REPORT.md)
|
|
5087
|
+
file: "src/tools/jfind/cascade.ts",
|
|
5088
|
+
marker: "// HANSE: find hierarchy imports (folder request builder)",
|
|
5089
|
+
anchor: "import { type HeatRange, mergeHeat, type Passage, plainContent, selectWindows, sketch, windows } from \"./passages\";\nimport { nameBatch, passageBatch, passageKey, entryKey, type Request, type SketchCard, sketchBatch } from \"./questions\";\nimport { lines, readText, ReadTextError, takeChars } from \"./text\";\n",
|
|
5090
|
+
patched: "import { type HeatRange, mergeHeat, type Passage, plainContent, selectWindows, sketch, windows } from \"./passages\";\n// HANSE: find hierarchy imports (folder request builder)\nimport {\n\ttype DirCard,\n\tdirBatch,\n\tentryKey,\n\tnameBatch,\n\tpassageBatch,\n\tpassageKey,\n\ttype Request,\n\ttype SketchCard,\n\tsketchBatch,\n} from \"./questions\";\nimport { lines, readText, ReadTextError, takeChars } from \"./text\";\n",
|
|
5091
|
+
},
|
|
5092
|
+
{
|
|
5093
|
+
// find: hierarchical directory pass admits files past the lexical candidate cap (jevgrep comparison, .omp/jevgrep-comparison/REPORT.md)
|
|
5094
|
+
file: "src/tools/jfind/cascade.ts",
|
|
5095
|
+
marker: "// HANSE: find hierarchy directory pass limits (fixed by the jevgrep comparison experiment)",
|
|
5096
|
+
anchor: "const FAILURES_KEPT = 5;\n\n",
|
|
5097
|
+
patched: "const FAILURES_KEPT = 5;\n// HANSE: find hierarchy directory pass limits (fixed by the jevgrep comparison experiment)\n/** Folder probability at or above which a folder is expanded and its capped-out files are admitted. */\nconst DIR_P = 0.5;\n/** Folders per folder-judgment request. */\nconst DIR_BATCH = 64;\n/** Child names shown per folder card. */\nconst DIR_SAMPLE = 32;\n/** Capped-out files that receive a filename judgment. */\nconst EXTRA_CANDIDATES = 128;\n/** Capped-out files whose content is read and sketched. */\nconst EXTRA_FILES = 10;\n\n",
|
|
5098
|
+
},
|
|
5099
|
+
{
|
|
5100
|
+
// find: hierarchical directory pass admits files past the lexical candidate cap (jevgrep comparison, .omp/jevgrep-comparison/REPORT.md)
|
|
5101
|
+
file: "src/tools/jfind/cascade.ts",
|
|
5102
|
+
marker: "// HANSE: find hierarchical directory pass methods",
|
|
5103
|
+
anchor: "\n\tasync run(): Promise<CascadeResult> {\n\t\tconst { root, filesystem, query, includeHidden, signal, onProgress } = this.#options;\n\t\tconst keywords = deriveKeywords(query, this.#options.extraKeywords);\n\n\t\tonProgress?.(\"lexical scan\");\n\t\tconst native = filesystem.shellFilesystem();\n\t\tconst [entries, index] = await Promise.all([\n\t\t\tlistFiles(root, { includeHidden, filesystem: native, signal }),\n\t\t\tgrepIndex(root.path, keywords, { includeHidden, filesystem: native, signal, timeoutMs: SCAN_TIMEOUT_MS }),\n\t\t]);\n\t\tthis.stats.listed = entries.length;\n\t\tconst weights = idf(index);\n\t\tconst noCounts = Array.from({ length: keywords.length }, () => 0);\n\t\tconst ranked = entries\n\t\t\t.map((entry, node) => ({\n\t\t\t\tnode,\n\t\t\t\tlex: fileScore(index.perFileKw.get(entry.rel) ?? noCounts, weights, entry.rel, keywords),\n\t\t\t}))\n\t\t\t.sort((a, b) => b.lex - a.lex || compareRel(entries[a.node]!, entries[b.node]!))\n\t\t\t.slice(0, CANDIDATES);\n\t\tconst nameScore = Array.from<number | undefined>({ length: entries.length });\n\n\t\t// Wave 1: filename ranking over the lexical shortlist.\n\t\tconst project = path.basename(root.path);\n\t\tconst nameJobs = chunks(\n\t\t\tranked.map(candidate => candidate.node),\n\t\t\tNAME_BATCH,\n\t\t).map(batch => ({\n\t\t\tbatch,\n",
|
|
5104
|
+
patched: "\n\t// HANSE: find hierarchical directory pass methods\n\t/**\n\t * Hierarchical directory pass, run after the lexical cascade has produced its hits. Files past the\n\t * lexical candidate cap are otherwise never judged: folders judged relevant admit their capped-out\n\t * files to a filename judgment and a few reads through the same sketch and verify stages. The\n\t * baseline `hits` are never changed. A provider failure or the overall timeout keeps them and is\n\t * reported in `stats.failures`; a user cancel keeps its existing meaning and throws.\n\t */\n\tasync #hierarchy(\n\t\tentries: readonly FileEntry[],\n\t\toutside: readonly { node: number }[],\n\t\tnameScore: (number | undefined)[],\n\t\tkeywords: readonly string[],\n\t\tweights: readonly number[],\n\t\thits: FindHit[],\n\t): Promise<void> {\n\t\tif (outside.length === 0) return;\n\t\tconst { signal } = this.#options;\n\t\ttry {\n\t\t\thits.push(...(await this.#extraHits(entries, outside, nameScore, keywords, weights)));\n\t\t} catch (error) {\n\t\t\tconst timedOut = signal?.reason instanceof DOMException && signal.reason.name === \"TimeoutError\";\n\t\t\tif (signal?.aborted && !timedOut) throw error;\n\t\t\tthis.#fail(\"hierarchy\", error);\n\t\t}\n\t}\n\n\tasync #extraHits(\n\t\tentries: readonly FileEntry[],\n\t\toutside: readonly { node: number }[],\n\t\tnameScore: (number | undefined)[],\n\t\tkeywords: readonly string[],\n\t\tweights: readonly number[],\n\t): Promise<FindHit[]> {\n\t\tconst { root, query } = this.#options;\n\t\tconst project = path.basename(root.path);\n\n\t\t// Folder model over the eligible files: eligibility, gitignore, hidden and secret rules already applied.\n\t\tinterface Folder {\n\t\t\tfiles: number[];\n\t\t\tsubdirs: Set<string>;\n\t\t\ttotal: number;\n\t\t}\n\t\tconst folders = new Map<string, Folder>();\n\t\tconst folder = (dir: string): Folder => {\n\t\t\tlet found = folders.get(dir);\n\t\t\tif (!found) {\n\t\t\t\tfound = { files: [], subdirs: new Set(), total: 0 };\n\t\t\t\tfolders.set(dir, found);\n\t\t\t}\n\t\t\treturn found;\n\t\t};\n\t\tentries.forEach((entry, node) => {\n\t\t\tconst segments = entry.rel.split(\"/\");\n\t\t\tsegments.pop();\n\t\t\tlet dir = \"\";\n\t\t\tfolder(\"\").total++;\n\t\t\tfor (const segment of segments) {\n\t\t\t\tconst child = dir ? `${dir}/${segment}` : segment;\n\t\t\t\tfolder(dir).subdirs.add(child);\n\t\t\t\tfolder(child).total++;\n\t\t\t\tdir = child;\n\t\t\t}\n\t\t\tfolder(dir).files.push(node);\n\t\t});\n\t\tconst baseName = (rel: string) => rel.slice(rel.lastIndexOf(\"/\") + 1);\n\t\tconst card = (dir: string): DirCard => {\n\t\t\tconst found = folder(dir);\n\t\t\tconst names = [...found.subdirs]\n\t\t\t\t.map(sub => `${baseName(sub)}/`)\n\t\t\t\t.sort()\n\t\t\t\t.concat(found.files.map(node => baseName(entries[node]!.rel)).sort());\n\t\t\tconst step = Math.max(1, names.length / DIR_SAMPLE);\n\t\t\tconst sample = Array.from({ length: Math.min(DIR_SAMPLE, names.length) }, (_, i) => names[Math.floor(i * step)]!);\n\t\t\tconst extensions: Record<string, number> = {};\n\t\t\tfor (const node of found.files) {\n\t\t\t\tconst ext = /\\.[^./]+$/.exec(entries[node]!.rel)?.[0] ?? \"(none)\";\n\t\t\t\textensions[ext] = (extensions[ext] ?? 0) + 1;\n\t\t\t}\n\t\t\tconst counts = Object.entries(extensions)\n\t\t\t\t.sort((a, b) => b[1] - a[1])\n\t\t\t\t.slice(0, 6)\n\t\t\t\t.map(([ext, n]) => `${ext} ${n}`)\n\t\t\t\t.join(\", \");\n\t\t\treturn {\n\t\t\t\tpath: dir,\n\t\t\t\tsummary: `${found.total} files under it, ${found.files.length} direct, ${found.subdirs.size} subfolders; extensions: ${counts || \"none\"}; sample: ${sample.join(\", \")}`,\n\t\t\t};\n\t\t};\n\n\t\t// Level by level: a folder judged relevant is expanded and admits its direct files. An unusable\n\t\t// or failed judgment admits nothing, so a failure can only lose extra coverage, never baseline hits.\n\t\tconst admitted = new Set<string>([\"\"]);\n\t\tlet frontier = [...folder(\"\").subdirs].sort();\n\t\twhile (frontier.length > 0) {\n\t\t\tconst next: string[] = [];\n\t\t\tconst jobs = chunks(frontier, DIR_BATCH).map(batch => ({ batch, request: dirBatch(project, query, batch.map(card)) }));\n\t\t\tawait this.#dispatch(jobs, (job, outcome) => {\n\t\t\t\tif (!outcome.ok) this.#fail(\"folders\", outcome.error);\n\t\t\t\tjob.batch.forEach((dir, k) => {\n\t\t\t\t\tconst p = noul(outcome, entryKey(k));\n\t\t\t\t\tif (p === undefined) {\n\t\t\t\t\t\tif (outcome.ok) this.stats.errors++;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (p >= DIR_P) {\n\t\t\t\t\t\tadmitted.add(dir);\n\t\t\t\t\t\tnext.push(...folder(dir).subdirs);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t});\n\t\t\tfrontier = next.sort();\n\t\t}\n\n\t\t// `outside` is already in lexical order, so the first admitted ones are the strongest.\n\t\tconst dirOf = (rel: string) => rel.slice(0, Math.max(0, rel.lastIndexOf(\"/\")));\n\t\tconst extra: number[] = [];\n\t\tfor (const { node } of outside) {\n\t\t\tif (extra.length >= EXTRA_CANDIDATES) break;\n\t\t\tif (admitted.has(dirOf(entries[node]!.rel))) extra.push(node);\n\t\t}\n\t\tif (extra.length === 0) return [];\n\n\t\tconst nameJobs = chunks(extra, NAME_BATCH).map(batch => ({\n\t\t\tbatch,\n",
|
|
5105
|
+
},
|
|
5106
|
+
{
|
|
5107
|
+
// find: hierarchical directory pass admits files past the lexical candidate cap (jevgrep comparison, .omp/jevgrep-comparison/REPORT.md)
|
|
5108
|
+
file: "src/tools/jfind/cascade.ts",
|
|
5109
|
+
marker: "if (!outcome.ok) this.#fail(\"extra filenames\", outcome.error);",
|
|
5110
|
+
anchor: "\t\t}));\n\t\tlet named = 0;\n\t\tonProgress?.(`filename ranking 0/${ranked.length}`);\n\t\tawait this.#dispatch(nameJobs, (job, outcome) => {\n\t\t\tnamed += job.batch.length;\n\t\t\tif (!outcome.ok) this.#fail(\"filenames\", outcome.error);\n\t\t\tjob.batch.forEach((node, k) => {\n",
|
|
5111
|
+
patched: "\t\t}));\n\t\tawait this.#dispatch(nameJobs, (job, outcome) => {\n\t\t\tif (!outcome.ok) this.#fail(\"extra filenames\", outcome.error);\n\t\t\tjob.batch.forEach((node, k) => {\n",
|
|
5112
|
+
},
|
|
5113
|
+
{
|
|
5114
|
+
// find: hierarchical directory pass admits files past the lexical candidate cap (jevgrep comparison, .omp/jevgrep-comparison/REPORT.md)
|
|
5115
|
+
file: "src/tools/jfind/cascade.ts",
|
|
5116
|
+
marker: "// Stable sort: ties keep the lexical order of `extra`.",
|
|
5117
|
+
anchor: "\t\t\t});\n\t\t\tonProgress?.(`filename ranking ${named}/${ranked.length}`);\n\t\t});\n\n\t\t// The two strongest lexical candidates are read regardless of the name\n\t\t// judgment; the rest of the budget follows name score, then lexical rank.\n\t\tconst selected = ranked.slice(0, Math.min(FILES, 2)).map(candidate => candidate.node);\n\t\tranked.sort((a, b) => (nameScore[b.node] ?? 0) - (nameScore[a.node] ?? 0) || b.lex - a.lex);\n\t\tfor (const candidate of ranked) {\n\t\t\tif (selected.length >= FILES) break;\n\t\t\tif (!selected.includes(candidate.node)) selected.push(candidate.node);\n\t\t}\n\t\tonProgress?.(`reading ${selected.length} files`);\n",
|
|
5118
|
+
patched: "\t\t\t});\n\t\t});\n\t\t// Stable sort: ties keep the lexical order of `extra`.\n\t\tconst selected = [...extra].sort((a, b) => (nameScore[b] ?? 0) - (nameScore[a] ?? 0)).slice(0, EXTRA_FILES);\n\t\treturn this.#readAndVerify(selected, entries, nameScore, keywords, weights);\n\t}\n\n\t/**\n\t * Read, sketch and verify the `selected` files: sketch routing over mixed-file cards, then verification\n\t * of the complete passages that survive. Shared by the lexical cascade and the hierarchical pass.\n\t */\n\tasync #readAndVerify(\n\t\tselected: readonly number[],\n\t\tentries: readonly FileEntry[],\n\t\tnameScore: readonly (number | undefined)[],\n\t\tkeywords: readonly string[],\n\t\tweights: readonly number[],\n\t): Promise<FindHit[]> {\n\t\tconst { filesystem, query, onProgress } = this.#options;\n\t\tonProgress?.(`reading ${selected.length} files`);\n",
|
|
5119
|
+
},
|
|
5120
|
+
{
|
|
5121
|
+
// find: hierarchical directory pass admits files past the lexical candidate cap (jevgrep comparison, .omp/jevgrep-comparison/REPORT.md)
|
|
5122
|
+
file: "src/tools/jfind/cascade.ts",
|
|
5123
|
+
marker: "// HANSE: lexRanked keeps the capped-out tail for the hierarchical directory pass",
|
|
5124
|
+
anchor: "\t\tthis.stats.filesRead += files.length - results.size;\n\t\thits.sort((a, b) => b.contentScore - a.contentScore);\n",
|
|
5125
|
+
patched: "\t\tthis.stats.filesRead += files.length - results.size;\n\t\treturn hits;\n\t}\n\n\tasync run(): Promise<CascadeResult> {\n\t\tconst { root, filesystem, query, includeHidden, signal, onProgress } = this.#options;\n\t\tconst keywords = deriveKeywords(query, this.#options.extraKeywords);\n\n\t\tonProgress?.(\"lexical scan\");\n\t\tconst native = filesystem.shellFilesystem();\n\t\tconst [entries, index] = await Promise.all([\n\t\t\tlistFiles(root, { includeHidden, filesystem: native, signal }),\n\t\t\tgrepIndex(root.path, keywords, { includeHidden, filesystem: native, signal, timeoutMs: SCAN_TIMEOUT_MS }),\n\t\t]);\n\t\tthis.stats.listed = entries.length;\n\t\tconst weights = idf(index);\n\t\tconst noCounts = Array.from({ length: keywords.length }, () => 0);\n\t\t// HANSE: lexRanked keeps the capped-out tail for the hierarchical directory pass\n\t\tconst lexRanked = entries\n\t\t\t.map((entry, node) => ({\n\t\t\t\tnode,\n\t\t\t\tlex: fileScore(index.perFileKw.get(entry.rel) ?? noCounts, weights, entry.rel, keywords),\n\t\t\t}))\n\t\t\t.sort((a, b) => b.lex - a.lex || compareRel(entries[a.node]!, entries[b.node]!));\n\t\tconst ranked = lexRanked.slice(0, CANDIDATES);\n\t\tconst nameScore = Array.from<number | undefined>({ length: entries.length });\n\n\t\t// Wave 1: filename ranking over the lexical shortlist.\n\t\tconst project = path.basename(root.path);\n\t\tconst nameJobs = chunks(\n\t\t\tranked.map(candidate => candidate.node),\n\t\t\tNAME_BATCH,\n\t\t).map(batch => ({\n\t\t\tbatch,\n\t\t\trequest: nameBatch(\n\t\t\t\tproject,\n\t\t\t\tquery,\n\t\t\t\tbatch.map(node => entries[node]!),\n\t\t\t),\n\t\t}));\n\t\tlet named = 0;\n\t\tonProgress?.(`filename ranking 0/${ranked.length}`);\n\t\tawait this.#dispatch(nameJobs, (job, outcome) => {\n\t\t\tnamed += job.batch.length;\n\t\t\tif (!outcome.ok) this.#fail(\"filenames\", outcome.error);\n\t\t\tjob.batch.forEach((node, k) => {\n\t\t\t\tconst p = noul(outcome, entryKey(k));\n\t\t\t\tnameScore[node] = p;\n\t\t\t\tif (p === undefined) {\n\t\t\t\t\tif (outcome.ok) this.stats.errors++;\n\t\t\t\t} else {\n\t\t\t\t\tthis.stats.judged++;\n\t\t\t\t}\n\t\t\t});\n\t\t\tonProgress?.(`filename ranking ${named}/${ranked.length}`);\n\t\t});\n\n\t\t// The two strongest lexical candidates are read regardless of the name\n\t\t// judgment; the rest of the budget follows name score, then lexical rank.\n\t\tconst selected = ranked.slice(0, Math.min(FILES, 2)).map(candidate => candidate.node);\n\t\tranked.sort((a, b) => (nameScore[b.node] ?? 0) - (nameScore[a.node] ?? 0) || b.lex - a.lex);\n\t\tfor (const candidate of ranked) {\n\t\t\tif (selected.length >= FILES) break;\n\t\t\tif (!selected.includes(candidate.node)) selected.push(candidate.node);\n\t\t}\n\t\tconst hits = await this.#readAndVerify(selected, entries, nameScore, keywords, weights);\n\t\t// HANSE: hierarchical directory pass over the capped-out files; baseline hits are kept\n\t\tawait this.#hierarchy(entries, lexRanked.slice(CANDIDATES), nameScore, keywords, weights, hits);\n\t\thits.sort((a, b) => b.contentScore - a.contentScore);\n",
|
|
5126
|
+
},
|
|
5127
|
+
{
|
|
5128
|
+
// find: hierarchical directory pass admits files past the lexical candidate cap (jevgrep comparison, .omp/jevgrep-comparison/REPORT.md)
|
|
5129
|
+
file: "src/tools/jfind/questions.ts",
|
|
5130
|
+
marker: "// HANSE: find hierarchy directory pass request",
|
|
5131
|
+
anchor: "\n/** One sketch card: the file it came from and its budgeted verbatim lines. */\n",
|
|
5132
|
+
patched: "\n// HANSE: find hierarchy directory pass request\n/** One folder card of the hierarchical directory pass: its path and the metadata shown after its tag. */\nexport interface DirCard {\n\tpath: string;\n\tsummary: string;\n}\n\nconst DIR_FORMAT =\n\t\"`tree` lists folders. Each line starts with `#` and a tag like e017, then the folder path, its file count, subfolders, extension counts and a sample of child names.\";\n\n/** One noul per folder over a shared listing of folder metadata (counts, extensions, sampled child names). */\nexport function dirBatch(project: string, query: string, cards: readonly DirCard[]): Request {\n\tconst questions: Record<string, NoulQuestion> = {};\n\tconst tree: string[] = [];\n\tcards.forEach((card, i) => {\n\t\tconst key = entryKey(i);\n\t\ttree.push(`# ${key} ${card.path}/ — ${card.summary}`);\n\t\tquestions[key] = {\n\t\t\ttype: \"noul\",\n\t\t\tinstructions: `Is the folder tagged ${key} (\"${card.path}/\") likely to hold, at any depth, a file matching this search: \"${query}\"? Judge by its name, counts and sampled names in \\`tree\\`; apply \\`criteria.folder\\`.`,\n\t\t};\n\t});\n\treturn {\n\t\tstate: { criteria: { folder: FOLDER_CRITERIA }, format: DIR_FORMAT, project, search: query, task: TASK, tree: tree.join(\"\\n\") },\n\t\tquestions,\n\t};\n}\n\n/** One sketch card: the file it came from and its budgeted verbatim lines. */\n",
|
|
5133
|
+
},
|
|
4992
5134
|
{
|
|
4993
5135
|
// 2026-09-25: 사유를 직접 준 skipped 결과(user steering 보류, soft-required 도구 안내)에도 core 가
|
|
4994
5136
|
// "the assistant ended its turn" 을 앞에 붙여, 턴이 이어지는데 끝난 것처럼 읽혔다. 사유가 있으면
|
|
@@ -5494,6 +5636,791 @@ function matchesFilter(window: DesktopWindow, filter?: WindowFilter): boolean {
|
|
|
5494
5636
|
assertWindowFilter(filter);
|
|
5495
5637
|
const { signal } = getContext();`,
|
|
5496
5638
|
},
|
|
5639
|
+
{
|
|
5640
|
+
// MCP 선택 연결: 요청별 MCP 선택(opt-in). all이 기본이며 per-request면 시작 시 연결하지 않고 요청마다 확장이 고른다.
|
|
5641
|
+
file: "src/mcp/settings.ts",
|
|
5642
|
+
marker: ` "all: connect every enabled server at startup. per-request: connect nothing at startup; before each request an extension selects which enabled servers to connect (new sessions)",`,
|
|
5643
|
+
anchor: ` description: "Wait this many milliseconds for initial MCP tool discovery; 0 waits until connections settle",
|
|
5644
|
+
},`,
|
|
5645
|
+
patched: ` description: "Wait this many milliseconds for initial MCP tool discovery; 0 waits until connections settle",
|
|
5646
|
+
},
|
|
5647
|
+
});
|
|
5648
|
+
|
|
5649
|
+
export const cfgMcpSelection = register({
|
|
5650
|
+
id: "mcp.selection",
|
|
5651
|
+
type: "enum",
|
|
5652
|
+
values: ["all", "per-request"] as const,
|
|
5653
|
+
default: "all",
|
|
5654
|
+
ui: {
|
|
5655
|
+
tab: "tools",
|
|
5656
|
+
group: "Discovery & MCP",
|
|
5657
|
+
label: "MCP Connection Selection",
|
|
5658
|
+
description:
|
|
5659
|
+
"all: connect every enabled server at startup. per-request: connect nothing at startup; before each request an extension selects which enabled servers to connect (new sessions)",
|
|
5660
|
+
},`,
|
|
5661
|
+
},
|
|
5662
|
+
{
|
|
5663
|
+
// MCP 선택 연결: per-request: discovery·설정 reconcile은 허용된 config를 deferred 후보로만 적재하고, 선택된 이름만 connectDeferred로 연결한다. 수동 /mcp reconnect는 deferred 후보를 바로 연결한다.
|
|
5664
|
+
file: "src/mcp/manager.ts",
|
|
5665
|
+
marker: ` /** Tool names/descriptions from the last successful connect with the same config, if cached. Untrusted server text. */`,
|
|
5666
|
+
anchor: `
|
|
5667
|
+
/** Handles an MCP \`WWW-Authenticate\` challenge and returns refreshed config. */`,
|
|
5668
|
+
patched: `
|
|
5669
|
+
/**
|
|
5670
|
+
* Public metadata of an enabled, allowed MCP server whose connection is
|
|
5671
|
+
* deferred until a per-request selection (\`mcp.selection: per-request\`).
|
|
5672
|
+
* Carries no headers, env, args, URLs, or credentials.
|
|
5673
|
+
*/
|
|
5674
|
+
export interface MCPDeferredServer {
|
|
5675
|
+
name: string;
|
|
5676
|
+
/** Config level the server was loaded from. */
|
|
5677
|
+
level: SourceMeta["level"] | "unknown";
|
|
5678
|
+
/** Discovery provider id (native, mcp-json, plugin providers, ...). */
|
|
5679
|
+
provider: string;
|
|
5680
|
+
transport: "stdio" | "http" | "sse";
|
|
5681
|
+
/** Tool names/descriptions from the last successful connect with the same config, if cached. Untrusted server text. */
|
|
5682
|
+
cachedTools: Array<{ name: string; description?: string }>;
|
|
5683
|
+
}
|
|
5684
|
+
|
|
5685
|
+
/** Handles an MCP \`WWW-Authenticate\` challenge and returns refreshed config. */`,
|
|
5686
|
+
},
|
|
5687
|
+
{
|
|
5688
|
+
// MCP 선택 연결 r3: 선택이 연결한 서버 소유를 추적한다(수동 reconnect·disconnect가 지운다). legacyPatched는 r2 적용본을 r3로 올린다.
|
|
5689
|
+
file: "src/mcp/manager.ts",
|
|
5690
|
+
marker: ` /** Connected by per-request selection; a manual reconnect or disconnect clears it. */`,
|
|
5691
|
+
anchor: ` /** Settles when the latest {@link MCPManager.discoverAndConnect} call does; reconciles wait on it. */
|
|
5692
|
+
#discoveryInFlight: Promise<unknown> = Promise.resolve();
|
|
5693
|
+
/**
|
|
5694
|
+
* Timestamps of recent reconnectServer invocations per server, used by the`,
|
|
5695
|
+
legacyPatched: ` /** Settles when the latest {@link MCPManager.discoverAndConnect} call does; reconciles wait on it. */
|
|
5696
|
+
#discoveryInFlight: Promise<unknown> = Promise.resolve();
|
|
5697
|
+
/** When set, discovery records allowed configs as deferred instead of connecting them. */
|
|
5698
|
+
#deferConnect = false;
|
|
5699
|
+
#deferred = new Map<string, { config: MCPServerConfig; source?: SourceMeta }>();
|
|
5700
|
+
/**
|
|
5701
|
+
* Timestamps of recent reconnectServer invocations per server, used by the`,
|
|
5702
|
+
patched: ` /** Settles when the latest {@link MCPManager.discoverAndConnect} call does; reconciles wait on it. */
|
|
5703
|
+
#discoveryInFlight: Promise<unknown> = Promise.resolve();
|
|
5704
|
+
/** When set, discovery records allowed configs as deferred instead of connecting them. */
|
|
5705
|
+
#deferConnect = false;
|
|
5706
|
+
#deferred = new Map<string, { config: MCPServerConfig; source?: SourceMeta }>();
|
|
5707
|
+
/** Connected by per-request selection; a manual reconnect or disconnect clears it. */
|
|
5708
|
+
#selectionConnected = new Set<string>();
|
|
5709
|
+
/**
|
|
5710
|
+
* Timestamps of recent reconnectServer invocations per server, used by the`,
|
|
5711
|
+
},
|
|
5712
|
+
{
|
|
5713
|
+
file: "src/mcp/manager.ts",
|
|
5714
|
+
marker: ` const result = await this.#connectOrDefer(configs, sources, options?.onStatus, options?.startupTimeoutMs);`,
|
|
5715
|
+
anchor: ` const { configs, exaApiKeys, sources } = loadedConfigs;
|
|
5716
|
+
const result = await this.connectServers(configs, sources, options?.onStatus, options?.startupTimeoutMs);
|
|
5717
|
+
result.exaApiKeys = exaApiKeys;`,
|
|
5718
|
+
patched: ` const { configs, exaApiKeys, sources } = loadedConfigs;
|
|
5719
|
+
const result = await this.#connectOrDefer(configs, sources, options?.onStatus, options?.startupTimeoutMs);
|
|
5720
|
+
result.exaApiKeys = exaApiKeys;`,
|
|
5721
|
+
},
|
|
5722
|
+
{
|
|
5723
|
+
// MCP 선택 연결 r3: 이미 취소된 요청은 아무것도 시작하지 않고, handshake 중 취소는 미연결 서버를 해제해 deferred 후보로 되돌린다(늦은 연결 없음). legacyPatched는 r2 적용본.
|
|
5724
|
+
file: "src/mcp/manager.ts",
|
|
5725
|
+
marker: ` * are ignored. An already-aborted signal starts nothing; an abort mid-handshake drops the servers`,
|
|
5726
|
+
anchor: ` return result;
|
|
5727
|
+
}`,
|
|
5728
|
+
legacyPatched: ` return result;
|
|
5729
|
+
}
|
|
5730
|
+
|
|
5731
|
+
/**
|
|
5732
|
+
* Opt into per-request selection: discovery and config reconciles record
|
|
5733
|
+
* allowed servers as deferred candidates instead of connecting them.
|
|
5734
|
+
* Explicit connects (\`connectServers\`, \`/mcp enable\`, \`/mcp reconnect\`) still connect.
|
|
5735
|
+
*/
|
|
5736
|
+
setDeferConnect(enabled: boolean): void {
|
|
5737
|
+
this.#deferConnect = enabled;
|
|
5738
|
+
}
|
|
5739
|
+
|
|
5740
|
+
async #connectOrDefer(
|
|
5741
|
+
configs: Record<string, MCPServerConfig>,
|
|
5742
|
+
sources: Record<string, SourceMeta>,
|
|
5743
|
+
onStatus?: (event: McpConnectionStatusEvent) => void,
|
|
5744
|
+
startupTimeoutMs?: number,
|
|
5745
|
+
): Promise<MCPLoadResult> {
|
|
5746
|
+
if (!this.#deferConnect) return this.connectServers(configs, sources, onStatus, startupTimeoutMs);
|
|
5747
|
+
for (const [name, config] of Object.entries(configs)) {
|
|
5748
|
+
if (this.#connections.has(name) || this.#pendingConnections.has(name)) continue;
|
|
5749
|
+
const source = sources[name];
|
|
5750
|
+
this.#deferred.set(name, { config, source });
|
|
5751
|
+
if (source) this.#sources.set(name, source);
|
|
5752
|
+
}
|
|
5753
|
+
return { tools: this.#tools, errors: new Map(), connectedServers: this.getConnectedServers(), exaApiKeys: [] };
|
|
5754
|
+
}
|
|
5755
|
+
|
|
5756
|
+
/** Deferred candidates after the in-flight discovery settles. */
|
|
5757
|
+
async getDeferredServers(): Promise<MCPDeferredServer[]> {
|
|
5758
|
+
await this.#discoveryInFlight;
|
|
5759
|
+
return Promise.all(
|
|
5760
|
+
Array.from(this.#deferred, async ([name, { config, source }]) => {
|
|
5761
|
+
const cached = (await this.toolCache?.get(name, config).catch(() => null)) ?? [];
|
|
5762
|
+
return {
|
|
5763
|
+
name,
|
|
5764
|
+
level: source?.level ?? "unknown",
|
|
5765
|
+
provider: source?.provider ?? "unknown",
|
|
5766
|
+
transport: config.type ?? "stdio",
|
|
5767
|
+
cachedTools: cached.map(tool => ({ name: tool.name, description: tool.description })),
|
|
5768
|
+
};
|
|
5769
|
+
}),
|
|
5770
|
+
);
|
|
5771
|
+
}
|
|
5772
|
+
|
|
5773
|
+
/**
|
|
5774
|
+
* Connect only the named deferred servers and wait until their handshakes
|
|
5775
|
+
* settle (or \`signal\` aborts). Unknown names are ignored.
|
|
5776
|
+
*/
|
|
5777
|
+
async connectDeferred(names: readonly string[], signal?: AbortSignal): Promise<MCPLoadResult> {
|
|
5778
|
+
const configs: Record<string, MCPServerConfig> = {};
|
|
5779
|
+
const sources: Record<string, SourceMeta> = {};
|
|
5780
|
+
for (const name of names) {
|
|
5781
|
+
const entry = this.#deferred.get(name);
|
|
5782
|
+
if (!entry) continue;
|
|
5783
|
+
configs[name] = entry.config;
|
|
5784
|
+
if (entry.source) sources[name] = entry.source;
|
|
5785
|
+
}
|
|
5786
|
+
const connect = this.connectServers(configs, sources, this.#discoverOptions?.onStatus, 0);
|
|
5787
|
+
if (!signal) return connect;
|
|
5788
|
+
const { promise: aborted, resolve } = Promise.withResolvers<MCPLoadResult>();
|
|
5789
|
+
const onAbort = () =>
|
|
5790
|
+
resolve({ tools: this.#tools, errors: new Map(), connectedServers: this.getConnectedServers(), exaApiKeys: [] });
|
|
5791
|
+
if (signal.aborted) onAbort();
|
|
5792
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
5793
|
+
try {
|
|
5794
|
+
return await Promise.race([connect, aborted]);
|
|
5795
|
+
} finally {
|
|
5796
|
+
signal.removeEventListener("abort", onAbort);
|
|
5797
|
+
}
|
|
5798
|
+
}`,
|
|
5799
|
+
patched: ` return result;
|
|
5800
|
+
}
|
|
5801
|
+
|
|
5802
|
+
/**
|
|
5803
|
+
* Opt into per-request selection: discovery and config reconciles record
|
|
5804
|
+
* allowed servers as deferred candidates instead of connecting them.
|
|
5805
|
+
* Explicit connects (\`connectServers\`, \`/mcp enable\`, \`/mcp reconnect\`) still connect.
|
|
5806
|
+
*/
|
|
5807
|
+
setDeferConnect(enabled: boolean): void {
|
|
5808
|
+
this.#deferConnect = enabled;
|
|
5809
|
+
}
|
|
5810
|
+
|
|
5811
|
+
async #connectOrDefer(
|
|
5812
|
+
configs: Record<string, MCPServerConfig>,
|
|
5813
|
+
sources: Record<string, SourceMeta>,
|
|
5814
|
+
onStatus?: (event: McpConnectionStatusEvent) => void,
|
|
5815
|
+
startupTimeoutMs?: number,
|
|
5816
|
+
): Promise<MCPLoadResult> {
|
|
5817
|
+
if (!this.#deferConnect) return this.connectServers(configs, sources, onStatus, startupTimeoutMs);
|
|
5818
|
+
for (const [name, config] of Object.entries(configs)) {
|
|
5819
|
+
if (this.#connections.has(name) || this.#pendingConnections.has(name)) continue;
|
|
5820
|
+
const source = sources[name];
|
|
5821
|
+
this.#deferred.set(name, { config, source });
|
|
5822
|
+
if (source) this.#sources.set(name, source);
|
|
5823
|
+
}
|
|
5824
|
+
return { tools: this.#tools, errors: new Map(), connectedServers: this.getConnectedServers(), exaApiKeys: [] };
|
|
5825
|
+
}
|
|
5826
|
+
|
|
5827
|
+
/** Deferred candidates after the in-flight discovery settles. */
|
|
5828
|
+
async getDeferredServers(): Promise<MCPDeferredServer[]> {
|
|
5829
|
+
await this.#discoveryInFlight;
|
|
5830
|
+
return Promise.all(
|
|
5831
|
+
Array.from(this.#deferred, async ([name, { config, source }]) => {
|
|
5832
|
+
const cached = (await this.toolCache?.get(name, config).catch(() => null)) ?? [];
|
|
5833
|
+
return {
|
|
5834
|
+
name,
|
|
5835
|
+
level: source?.level ?? "unknown",
|
|
5836
|
+
provider: source?.provider ?? "unknown",
|
|
5837
|
+
transport: config.type ?? "stdio",
|
|
5838
|
+
cachedTools: cached.map(tool => ({ name: tool.name, description: tool.description })),
|
|
5839
|
+
};
|
|
5840
|
+
}),
|
|
5841
|
+
);
|
|
5842
|
+
}
|
|
5843
|
+
|
|
5844
|
+
/** Whether per-request selection (not the user) connected this server. */
|
|
5845
|
+
isSelectionConnected(name: string): boolean {
|
|
5846
|
+
return this.#selectionConnected.has(name);
|
|
5847
|
+
}
|
|
5848
|
+
|
|
5849
|
+
/**
|
|
5850
|
+
* Connect only the named deferred servers and wait until their handshakes settle. Unknown names
|
|
5851
|
+
* are ignored. An already-aborted signal starts nothing; an abort mid-handshake drops the servers
|
|
5852
|
+
* not yet connected and restores them as deferred candidates, so no late connection survives.
|
|
5853
|
+
*/
|
|
5854
|
+
async connectDeferred(names: readonly string[], signal?: AbortSignal): Promise<MCPLoadResult> {
|
|
5855
|
+
const snapshot = (): MCPLoadResult => ({
|
|
5856
|
+
tools: this.#tools,
|
|
5857
|
+
errors: new Map(),
|
|
5858
|
+
connectedServers: this.getConnectedServers(),
|
|
5859
|
+
exaApiKeys: [],
|
|
5860
|
+
});
|
|
5861
|
+
if (signal?.aborted) return snapshot();
|
|
5862
|
+
const entries = new Map<string, { config: MCPServerConfig; source?: SourceMeta }>();
|
|
5863
|
+
const configs: Record<string, MCPServerConfig> = {};
|
|
5864
|
+
const sources: Record<string, SourceMeta> = {};
|
|
5865
|
+
for (const name of names) {
|
|
5866
|
+
const entry = this.#deferred.get(name);
|
|
5867
|
+
if (!entry) continue;
|
|
5868
|
+
entries.set(name, entry);
|
|
5869
|
+
configs[name] = entry.config;
|
|
5870
|
+
if (entry.source) sources[name] = entry.source;
|
|
5871
|
+
this.#selectionConnected.add(name);
|
|
5872
|
+
}
|
|
5873
|
+
const connect = this.connectServers(configs, sources, this.#discoverOptions?.onStatus, 0);
|
|
5874
|
+
if (!signal) return connect;
|
|
5875
|
+
const { promise: aborted, resolve } = Promise.withResolvers<"aborted">();
|
|
5876
|
+
const onAbort = () => resolve("aborted");
|
|
5877
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
5878
|
+
try {
|
|
5879
|
+
const outcome = await Promise.race([connect, aborted]);
|
|
5880
|
+
if (outcome !== "aborted") return outcome;
|
|
5881
|
+
} finally {
|
|
5882
|
+
signal.removeEventListener("abort", onAbort);
|
|
5883
|
+
}
|
|
5884
|
+
for (const [name, entry] of entries) {
|
|
5885
|
+
if (this.#connections.has(name)) continue;
|
|
5886
|
+
await this.disconnectServer(name);
|
|
5887
|
+
this.#deferred.set(name, entry);
|
|
5888
|
+
if (entry.source) this.#sources.set(name, entry.source);
|
|
5889
|
+
}
|
|
5890
|
+
return snapshot();
|
|
5891
|
+
}`,
|
|
5892
|
+
},
|
|
5893
|
+
{
|
|
5894
|
+
file: "src/mcp/manager.ts",
|
|
5895
|
+
marker: ` await this.#connectOrDefer(configs, sources, options.onStatus, options.startupTimeoutMs);`,
|
|
5896
|
+
anchor: ` if (!reconnect) return;
|
|
5897
|
+
await this.connectServers(configs, sources, options.onStatus, options.startupTimeoutMs);
|
|
5898
|
+
}`,
|
|
5899
|
+
patched: ` if (!reconnect) return;
|
|
5900
|
+
await this.#connectOrDefer(configs, sources, options.onStatus, options.startupTimeoutMs);
|
|
5901
|
+
}`,
|
|
5902
|
+
},
|
|
5903
|
+
{
|
|
5904
|
+
file: "src/mcp/manager.ts",
|
|
5905
|
+
marker: ` await this.#connectOrDefer(browserConfigs, browserSources, options?.onStatus, options?.startupTimeoutMs);`,
|
|
5906
|
+
anchor: ` if (!enabled) {
|
|
5907
|
+
await this.connectServers(browserConfigs, browserSources, options?.onStatus, options?.startupTimeoutMs);
|
|
5908
|
+
this.#discoverOptions = { ...options, filterBrowser: false };`,
|
|
5909
|
+
patched: ` if (!enabled) {
|
|
5910
|
+
await this.#connectOrDefer(browserConfigs, browserSources, options?.onStatus, options?.startupTimeoutMs);
|
|
5911
|
+
this.#discoverOptions = { ...options, filterBrowser: false };`,
|
|
5912
|
+
},
|
|
5913
|
+
{
|
|
5914
|
+
file: "src/mcp/manager.ts",
|
|
5915
|
+
marker: ` this.#deferred.delete(name);`,
|
|
5916
|
+
anchor: ` for (const [name, config] of Object.entries(configs)) {
|
|
5917
|
+
this.#startupServers.add(name);`,
|
|
5918
|
+
patched: ` for (const [name, config] of Object.entries(configs)) {
|
|
5919
|
+
this.#deferred.delete(name);
|
|
5920
|
+
this.#startupServers.add(name);`,
|
|
5921
|
+
},
|
|
5922
|
+
{
|
|
5923
|
+
// MCP 선택 연결 r3: 해제한 서버의 선택 소유 표시를 지운다. legacyPatched는 r2 적용본.
|
|
5924
|
+
file: "src/mcp/manager.ts",
|
|
5925
|
+
marker: ` this.#selectionConnected.delete(name);`,
|
|
5926
|
+
anchor: ` this.#sources.delete(name);
|
|
5927
|
+
this.#serverConfigs.delete(name);`,
|
|
5928
|
+
legacyPatched: ` this.#sources.delete(name);
|
|
5929
|
+
this.#deferred.delete(name);
|
|
5930
|
+
this.#serverConfigs.delete(name);`,
|
|
5931
|
+
patched: ` this.#sources.delete(name);
|
|
5932
|
+
this.#deferred.delete(name);
|
|
5933
|
+
this.#selectionConnected.delete(name);
|
|
5934
|
+
this.#serverConfigs.delete(name);`,
|
|
5935
|
+
},
|
|
5936
|
+
{
|
|
5937
|
+
// MCP 선택 연결 r3: 전체 해제 때 선택 소유 표시를 비운다. legacyPatched는 r2 적용본.
|
|
5938
|
+
file: "src/mcp/manager.ts",
|
|
5939
|
+
marker: ` this.#selectionConnected.clear();`,
|
|
5940
|
+
anchor: ` this.#sources.clear();
|
|
5941
|
+
this.#serverConfigs.clear();`,
|
|
5942
|
+
legacyPatched: ` this.#sources.clear();
|
|
5943
|
+
this.#deferred.clear();
|
|
5944
|
+
this.#serverConfigs.clear();`,
|
|
5945
|
+
patched: ` this.#sources.clear();
|
|
5946
|
+
this.#deferred.clear();
|
|
5947
|
+
this.#selectionConnected.clear();
|
|
5948
|
+
this.#serverConfigs.clear();`,
|
|
5949
|
+
},
|
|
5950
|
+
{
|
|
5951
|
+
// MCP 선택 연결 r3: 수동 /mcp reconnect는 소유를 사용자로 넘겨 이후 선택이 숨기지 않게 한다. legacyPatched는 r2 적용본.
|
|
5952
|
+
file: "src/mcp/manager.ts",
|
|
5953
|
+
marker: ` if (options?.manual) this.#selectionConnected.delete(name);`,
|
|
5954
|
+
anchor: ` ): Promise<MCPServerConnection | null> {
|
|
5955
|
+
if (options?.manual) {`,
|
|
5956
|
+
legacyPatched: ` ): Promise<MCPServerConnection | null> {
|
|
5957
|
+
const deferred = this.#deferred.get(name);
|
|
5958
|
+
if (deferred) {
|
|
5959
|
+
// A manual reconnect of a never-connected deferred candidate is an explicit user connect.
|
|
5960
|
+
await this.connectServers({ [name]: deferred.config }, deferred.source ? { [name]: deferred.source } : {}, undefined, 0);
|
|
5961
|
+
return this.#connections.get(name) ?? null;
|
|
5962
|
+
}
|
|
5963
|
+
if (options?.manual) {`,
|
|
5964
|
+
patched: ` ): Promise<MCPServerConnection | null> {
|
|
5965
|
+
if (options?.manual) this.#selectionConnected.delete(name);
|
|
5966
|
+
const deferred = this.#deferred.get(name);
|
|
5967
|
+
if (deferred) {
|
|
5968
|
+
// A manual reconnect of a never-connected deferred candidate is an explicit user connect.
|
|
5969
|
+
await this.connectServers({ [name]: deferred.config }, deferred.source ? { [name]: deferred.source } : {}, undefined, 0);
|
|
5970
|
+
return this.#connections.get(name) ?? null;
|
|
5971
|
+
}
|
|
5972
|
+
if (options?.manual) {`,
|
|
5973
|
+
},
|
|
5974
|
+
{
|
|
5975
|
+
// MCP 선택 연결: 비UI 경로(loader)도 같은 deferConnect를 manager에 전달한다.
|
|
5976
|
+
file: "src/mcp/loader.ts",
|
|
5977
|
+
marker: ` /** Record allowed servers as deferred candidates instead of connecting them (\`mcp.selection: per-request\`). */`,
|
|
5978
|
+
anchor: ` authStorage?: AuthStorage;
|
|
5979
|
+
}`,
|
|
5980
|
+
patched: ` authStorage?: AuthStorage;
|
|
5981
|
+
/** Record allowed servers as deferred candidates instead of connecting them (\`mcp.selection: per-request\`). */
|
|
5982
|
+
deferConnect?: boolean;
|
|
5983
|
+
}`,
|
|
5984
|
+
},
|
|
5985
|
+
{
|
|
5986
|
+
file: "src/mcp/loader.ts",
|
|
5987
|
+
marker: ` manager.setDeferConnect(options?.deferConnect ?? false);`,
|
|
5988
|
+
anchor: ` manager.setAuthStorage(options.authStorage);
|
|
5989
|
+
}
|
|
5990
|
+
|
|
5991
|
+
let result: MCPLoadResult;`,
|
|
5992
|
+
patched: ` manager.setAuthStorage(options.authStorage);
|
|
5993
|
+
}
|
|
5994
|
+
manager.setDeferConnect(options?.deferConnect ?? false);
|
|
5995
|
+
|
|
5996
|
+
let result: MCPLoadResult;`,
|
|
5997
|
+
},
|
|
5998
|
+
{
|
|
5999
|
+
// MCP 선택 연결: 소유 세션의 새 manager만 defer하고 선택기를 배선한다. child(options.mcpManager)는 부모 연결을 상속만 한다.
|
|
6000
|
+
file: "src/sdk.ts",
|
|
6001
|
+
marker: ` cfgMcpSelection,`,
|
|
6002
|
+
anchor: ` cfgMcpNotifications,
|
|
6003
|
+
cfgMcpStartupTimeoutMs,`,
|
|
6004
|
+
patched: ` cfgMcpNotifications,
|
|
6005
|
+
cfgMcpSelection,
|
|
6006
|
+
cfgMcpStartupTimeoutMs,`,
|
|
6007
|
+
},
|
|
6008
|
+
{
|
|
6009
|
+
file: "src/sdk.ts",
|
|
6010
|
+
marker: ` // Per-request selection (opt-in): only the owning session's fresh manager defers connects.`,
|
|
6011
|
+
anchor: ` }));
|
|
6012
|
+
const mcpDiscoverOptions = {`,
|
|
6013
|
+
patched: ` }));
|
|
6014
|
+
// Per-request selection (opt-in): only the owning session's fresh manager defers connects.
|
|
6015
|
+
const mcpSelectionPerRequest = cfgMcpSelection.get(settings) === "per-request";
|
|
6016
|
+
const mcpDiscoverOptions = {`,
|
|
6017
|
+
},
|
|
6018
|
+
{
|
|
6019
|
+
file: "src/sdk.ts",
|
|
6020
|
+
marker: ` mcpManager.setDeferConnect(mcpSelectionPerRequest);`,
|
|
6021
|
+
anchor: ` mcpManager.setAuthStorage(authStorage);
|
|
6022
|
+
toolSession.mcpManager = mcpManager;`,
|
|
6023
|
+
patched: ` mcpManager.setAuthStorage(authStorage);
|
|
6024
|
+
mcpManager.setDeferConnect(mcpSelectionPerRequest);
|
|
6025
|
+
toolSession.mcpManager = mcpManager;`,
|
|
6026
|
+
},
|
|
6027
|
+
{
|
|
6028
|
+
file: "src/sdk.ts",
|
|
6029
|
+
marker: ` deferConnect: mcpSelectionPerRequest,`,
|
|
6030
|
+
anchor: ` authStorage,
|
|
6031
|
+
});`,
|
|
6032
|
+
patched: ` authStorage,
|
|
6033
|
+
deferConnect: mcpSelectionPerRequest,
|
|
6034
|
+
});`,
|
|
6035
|
+
},
|
|
6036
|
+
{
|
|
6037
|
+
file: "src/sdk.ts",
|
|
6038
|
+
marker: ` if (mcpSelectionPerRequest) owningSession.setMCPSelectionManager(ownedMCPManager);`,
|
|
6039
|
+
anchor: ` await owningSession.refreshMCPTools(ownedMCPManager.getTools());
|
|
6040
|
+
});
|
|
6041
|
+
}
|
|
6042
|
+
`,
|
|
6043
|
+
patched: ` await owningSession.refreshMCPTools(ownedMCPManager.getTools());
|
|
6044
|
+
});
|
|
6045
|
+
// Only the owning session selects deferred servers; subagents reuse the parent's
|
|
6046
|
+
// manager and inherit its connections without connecting or disconnecting.
|
|
6047
|
+
if (mcpSelectionPerRequest) owningSession.setMCPSelectionManager(ownedMCPManager);
|
|
6048
|
+
}
|
|
6049
|
+
`,
|
|
6050
|
+
},
|
|
6051
|
+
{
|
|
6052
|
+
// MCP 선택 연결: 매 사용자 요청의 system prompt 전에 mcp_select를 보내 고른 deferred 서버만 연결하고 노출 subset을 적용한다. 실패는 새 연결 0.
|
|
6053
|
+
file: "src/session/agent-session.ts",
|
|
6054
|
+
marker: `import type { MCPManager } from "../mcp/manager";`,
|
|
6055
|
+
anchor: `} from "@oh-my-pi/pi-utils";
|
|
6056
|
+
import type { AdvisorConfig } from "@oh-my-pi/pi-tui/overlays/advisor-config";`,
|
|
6057
|
+
patched: `} from "@oh-my-pi/pi-utils";
|
|
6058
|
+
import type { MCPManager } from "../mcp/manager";
|
|
6059
|
+
import type { AdvisorConfig } from "@oh-my-pi/pi-tui/overlays/advisor-config";`,
|
|
6060
|
+
},
|
|
6061
|
+
{
|
|
6062
|
+
file: "src/session/agent-session.ts",
|
|
6063
|
+
marker: ` /** Owned MCP manager whose deferred servers are selected per request (\`mcp.selection: per-request\`). */`,
|
|
6064
|
+
anchor: ` #extensionRunner: ExtensionRunner | undefined = undefined;
|
|
6065
|
+
#getEvalPreludes: (() => readonly EvalPreludeDefinition[]) | undefined;`,
|
|
6066
|
+
patched: ` #extensionRunner: ExtensionRunner | undefined = undefined;
|
|
6067
|
+
/** Owned MCP manager whose deferred servers are selected per request (\`mcp.selection: per-request\`). */
|
|
6068
|
+
#mcpSelectionManager: MCPManager | undefined = undefined;
|
|
6069
|
+
#getEvalPreludes: (() => readonly EvalPreludeDefinition[]) | undefined;`,
|
|
6070
|
+
},
|
|
6071
|
+
{
|
|
6072
|
+
// MCP 선택 연결 r3: 이벤트에 취소 signal과 실제 연결 결과 outcome(connected/failed)을 싣고, 연결 목록에 선택 소유 여부를 붙인다. outcome은 항상 settle된다. legacyPatched는 r2 적용본.
|
|
6073
|
+
file: "src/session/agent-session.ts",
|
|
6074
|
+
marker: ` .map(name => ({ name, error: result.errors.get(name) ?? (signal?.aborted ? "cancelled" : "not connected") })),`,
|
|
6075
|
+
anchor: `
|
|
6076
|
+
/** Replaces host-owned RPC tools before the next model call. */`,
|
|
6077
|
+
legacyPatched: `
|
|
6078
|
+
/** Enables per-request MCP selection for the session that owns \`manager\`. */
|
|
6079
|
+
setMCPSelectionManager(manager: MCPManager | undefined): void {
|
|
6080
|
+
this.#mcpSelectionManager = manager;
|
|
6081
|
+
}
|
|
6082
|
+
|
|
6083
|
+
/**
|
|
6084
|
+
* Per-request MCP selection before the system prompt is built: extensions pick deferred servers
|
|
6085
|
+
* to connect (core connects only those) and which connected servers stay active. No handler, a
|
|
6086
|
+
* failing handler, or an empty answer connects nothing.
|
|
6087
|
+
*/
|
|
6088
|
+
async #selectMCPServersForPrompt(
|
|
6089
|
+
prompt: string,
|
|
6090
|
+
isCurrent: () => boolean,
|
|
6091
|
+
signal: AbortSignal | undefined,
|
|
6092
|
+
): Promise<void> {
|
|
6093
|
+
const manager = this.#mcpSelectionManager;
|
|
6094
|
+
const runner = this.#extensionRunner;
|
|
6095
|
+
if (!manager || !runner?.hasHandlers("mcp_select")) return;
|
|
6096
|
+
const deferred = await manager.getDeferredServers();
|
|
6097
|
+
if (!isCurrent()) return;
|
|
6098
|
+
const connectedByServer = new Map<string, Array<{ name: string; description?: string }>>();
|
|
6099
|
+
for (const tool of manager.getTools()) {
|
|
6100
|
+
if (!tool.mcpServerName) continue;
|
|
6101
|
+
let tools = connectedByServer.get(tool.mcpServerName);
|
|
6102
|
+
if (!tools) connectedByServer.set(tool.mcpServerName, (tools = []));
|
|
6103
|
+
tools.push({ name: tool.mcpToolName ?? tool.name, description: tool.description });
|
|
6104
|
+
}
|
|
6105
|
+
const connected = Array.from(connectedByServer, ([name, tools]) => {
|
|
6106
|
+
const source = manager.getSource(name);
|
|
6107
|
+
return {
|
|
6108
|
+
name,
|
|
6109
|
+
level: source?.level ?? ("unknown" as const),
|
|
6110
|
+
provider: source?.provider ?? "unknown",
|
|
6111
|
+
transport: manager.getServerConfig(name)?.type ?? ("stdio" as const),
|
|
6112
|
+
tools,
|
|
6113
|
+
};
|
|
6114
|
+
});
|
|
6115
|
+
const decision = await runner.emitMcpSelect(
|
|
6116
|
+
{
|
|
6117
|
+
type: "mcp_select",
|
|
6118
|
+
prompt,
|
|
6119
|
+
deferred: deferred.map(({ cachedTools, ...server }) => ({ ...server, tools: cachedTools })),
|
|
6120
|
+
connected,
|
|
6121
|
+
},
|
|
6122
|
+
signal,
|
|
6123
|
+
);
|
|
6124
|
+
if (!decision || !isCurrent()) return;
|
|
6125
|
+
const deferredNames = new Set(deferred.map(server => server.name));
|
|
6126
|
+
const connect = (decision.connect ?? []).filter(name => deferredNames.has(name));
|
|
6127
|
+
if (connect.length > 0) {
|
|
6128
|
+
const result = await manager.connectDeferred(connect, signal);
|
|
6129
|
+
for (const [name, error] of result.errors) {
|
|
6130
|
+
logger.warn("Selected MCP server failed to connect", { path: \`mcp:\${name}\`, error });
|
|
6131
|
+
}
|
|
6132
|
+
if (!isCurrent()) return;
|
|
6133
|
+
await this.refreshMCPTools(manager.getTools());
|
|
6134
|
+
}
|
|
6135
|
+
if (!decision.expose || !isCurrent()) return;
|
|
6136
|
+
const keep = new Set([...decision.expose, ...connect]);
|
|
6137
|
+
const hidden = new Set<string>();
|
|
6138
|
+
const shown: string[] = [];
|
|
6139
|
+
for (const tool of manager.getTools()) {
|
|
6140
|
+
if (!tool.mcpServerName) continue;
|
|
6141
|
+
if (keep.has(tool.mcpServerName)) shown.push(tool.name);
|
|
6142
|
+
else hidden.add(tool.name);
|
|
6143
|
+
}
|
|
6144
|
+
await this.setActiveToolsByName([
|
|
6145
|
+
...new Set([...this.getEnabledToolNames().filter(name => !hidden.has(name)), ...shown]),
|
|
6146
|
+
]);
|
|
6147
|
+
}
|
|
6148
|
+
|
|
6149
|
+
/** Replaces host-owned RPC tools before the next model call. */`,
|
|
6150
|
+
patched: `
|
|
6151
|
+
/** Enables per-request MCP selection for the session that owns \`manager\`. */
|
|
6152
|
+
setMCPSelectionManager(manager: MCPManager | undefined): void {
|
|
6153
|
+
this.#mcpSelectionManager = manager;
|
|
6154
|
+
}
|
|
6155
|
+
|
|
6156
|
+
/**
|
|
6157
|
+
* Per-request MCP selection before the system prompt is built: extensions pick deferred servers
|
|
6158
|
+
* to connect (core connects only those) and which connected servers stay active. No handler, a
|
|
6159
|
+
* failing handler, or an empty answer connects nothing. \`outcome\` always settles.
|
|
6160
|
+
*/
|
|
6161
|
+
async #selectMCPServersForPrompt(
|
|
6162
|
+
prompt: string,
|
|
6163
|
+
isCurrent: () => boolean,
|
|
6164
|
+
signal: AbortSignal | undefined,
|
|
6165
|
+
): Promise<void> {
|
|
6166
|
+
const manager = this.#mcpSelectionManager;
|
|
6167
|
+
const runner = this.#extensionRunner;
|
|
6168
|
+
if (!manager || !runner?.hasHandlers("mcp_select")) return;
|
|
6169
|
+
const { promise: outcome, resolve: settleOutcome } = Promise.withResolvers<{
|
|
6170
|
+
connected: string[];
|
|
6171
|
+
failed: Array<{ name: string; error: string }>;
|
|
6172
|
+
}>();
|
|
6173
|
+
try {
|
|
6174
|
+
const deferred = await manager.getDeferredServers();
|
|
6175
|
+
if (!isCurrent()) return;
|
|
6176
|
+
const connectedByServer = new Map<string, Array<{ name: string; description?: string }>>();
|
|
6177
|
+
for (const tool of manager.getTools()) {
|
|
6178
|
+
if (!tool.mcpServerName) continue;
|
|
6179
|
+
let tools = connectedByServer.get(tool.mcpServerName);
|
|
6180
|
+
if (!tools) connectedByServer.set(tool.mcpServerName, (tools = []));
|
|
6181
|
+
tools.push({ name: tool.mcpToolName ?? tool.name, description: tool.description });
|
|
6182
|
+
}
|
|
6183
|
+
const connected = Array.from(connectedByServer, ([name, tools]) => {
|
|
6184
|
+
const source = manager.getSource(name);
|
|
6185
|
+
return {
|
|
6186
|
+
name,
|
|
6187
|
+
level: source?.level ?? ("unknown" as const),
|
|
6188
|
+
provider: source?.provider ?? "unknown",
|
|
6189
|
+
transport: manager.getServerConfig(name)?.type ?? ("stdio" as const),
|
|
6190
|
+
tools,
|
|
6191
|
+
selected: manager.isSelectionConnected(name),
|
|
6192
|
+
};
|
|
6193
|
+
});
|
|
6194
|
+
const decision = await runner.emitMcpSelect(
|
|
6195
|
+
{
|
|
6196
|
+
type: "mcp_select",
|
|
6197
|
+
prompt,
|
|
6198
|
+
deferred: deferred.map(({ cachedTools, ...server }) => ({ ...server, tools: cachedTools })),
|
|
6199
|
+
connected,
|
|
6200
|
+
signal,
|
|
6201
|
+
outcome,
|
|
6202
|
+
},
|
|
6203
|
+
signal,
|
|
6204
|
+
);
|
|
6205
|
+
if (!decision || !isCurrent()) return;
|
|
6206
|
+
const deferredNames = new Set(deferred.map(server => server.name));
|
|
6207
|
+
const connect = (decision.connect ?? []).filter(name => deferredNames.has(name));
|
|
6208
|
+
if (connect.length > 0) {
|
|
6209
|
+
const result = await manager.connectDeferred(connect, signal);
|
|
6210
|
+
const live = new Set(manager.getConnectedServers());
|
|
6211
|
+
settleOutcome({
|
|
6212
|
+
connected: connect.filter(name => live.has(name)),
|
|
6213
|
+
failed: connect
|
|
6214
|
+
.filter(name => !live.has(name))
|
|
6215
|
+
.map(name => ({ name, error: result.errors.get(name) ?? (signal?.aborted ? "cancelled" : "not connected") })),
|
|
6216
|
+
});
|
|
6217
|
+
for (const [name, error] of result.errors) {
|
|
6218
|
+
logger.warn("Selected MCP server failed to connect", { path: \`mcp:\${name}\`, error });
|
|
6219
|
+
}
|
|
6220
|
+
if (!isCurrent()) return;
|
|
6221
|
+
await this.refreshMCPTools(manager.getTools());
|
|
6222
|
+
}
|
|
6223
|
+
if (!decision.expose || !isCurrent()) return;
|
|
6224
|
+
const keep = new Set([...decision.expose, ...connect]);
|
|
6225
|
+
const hidden = new Set<string>();
|
|
6226
|
+
const shown: string[] = [];
|
|
6227
|
+
for (const tool of manager.getTools()) {
|
|
6228
|
+
if (!tool.mcpServerName) continue;
|
|
6229
|
+
if (keep.has(tool.mcpServerName)) shown.push(tool.name);
|
|
6230
|
+
else hidden.add(tool.name);
|
|
6231
|
+
}
|
|
6232
|
+
await this.setActiveToolsByName([
|
|
6233
|
+
...new Set([...this.getEnabledToolNames().filter(name => !hidden.has(name)), ...shown]),
|
|
6234
|
+
]);
|
|
6235
|
+
} finally {
|
|
6236
|
+
settleOutcome({ connected: [], failed: [] });
|
|
6237
|
+
}
|
|
6238
|
+
}
|
|
6239
|
+
|
|
6240
|
+
/** Replaces host-owned RPC tools before the next model call. */`,
|
|
6241
|
+
},
|
|
6242
|
+
{
|
|
6243
|
+
file: "src/session/agent-session.ts",
|
|
6244
|
+
marker: ` await this.#selectMCPServersForPrompt(prompt, isCurrent, signal).catch(error => {`,
|
|
6245
|
+
anchor: ` const cancelled = { baseXdevCatalogDelivered: false, commit: () => undefined };
|
|
6246
|
+
for (let attempt = 0; attempt < AGENT_START_POLICY_MAX_ATTEMPTS; attempt++) {`,
|
|
6247
|
+
patched: ` const cancelled = { baseXdevCatalogDelivered: false, commit: () => undefined };
|
|
6248
|
+
await this.#selectMCPServersForPrompt(prompt, isCurrent, signal).catch(error => {
|
|
6249
|
+
logger.warn("MCP per-request selection failed; no new server connected", {
|
|
6250
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6251
|
+
});
|
|
6252
|
+
});
|
|
6253
|
+
if (!isCurrent()) return cancelled;
|
|
6254
|
+
for (let attempt = 0; attempt < AGENT_START_POLICY_MAX_ATTEMPTS; attempt++) {`,
|
|
6255
|
+
},
|
|
6256
|
+
{
|
|
6257
|
+
// MCP 선택 연결: mcp_select 이벤트: 공개 metadata(이름·scope·provider·transport·도구명/설명)만 싣고 결과는 이름 목록뿐이다.
|
|
6258
|
+
// MCP 선택 연결 r3: selected 소유 표시·McpSelectOutcome·signal/outcome 필드. legacyPatched는 r2 적용본.
|
|
6259
|
+
file: "src/extensibility/extensions/types.ts",
|
|
6260
|
+
marker: ` /** Connected servers only: true when per-request selection connected it; manual connects are false. */`,
|
|
6261
|
+
anchor: `}
|
|
6262
|
+
|
|
6263
|
+
export type {
|
|
6264
|
+
AgentEndEvent,`,
|
|
6265
|
+
legacyPatched: `}
|
|
6266
|
+
|
|
6267
|
+
/** One MCP server the owning session may connect or expose for the current request. Public metadata only. */
|
|
6268
|
+
export interface McpSelectServer {
|
|
6269
|
+
name: string;
|
|
6270
|
+
level: "user" | "project" | "native" | "unknown";
|
|
6271
|
+
provider: string;
|
|
6272
|
+
transport: "stdio" | "http" | "sse";
|
|
6273
|
+
/** Tool names/descriptions (cached for deferred servers, live for connected ones). Untrusted server text. */
|
|
6274
|
+
tools: Array<{ name: string; description?: string }>;
|
|
6275
|
+
}
|
|
6276
|
+
|
|
6277
|
+
/**
|
|
6278
|
+
* Fired in the session owning its MCP manager when \`mcp.selection\` is \`per-request\`, before each
|
|
6279
|
+
* ordinary prompt or dequeued user batch builds its system prompt. Deferred servers are enabled,
|
|
6280
|
+
* allowed configs not yet connected; handlers return names only and core performs the connect.
|
|
6281
|
+
*/
|
|
6282
|
+
export interface McpSelectEvent {
|
|
6283
|
+
type: "mcp_select";
|
|
6284
|
+
/** Same transformed text as \`before_agent_start\`. */
|
|
6285
|
+
prompt: string;
|
|
6286
|
+
deferred: McpSelectServer[];
|
|
6287
|
+
connected: McpSelectServer[];
|
|
6288
|
+
}
|
|
6289
|
+
|
|
6290
|
+
export type {
|
|
6291
|
+
AgentEndEvent,`,
|
|
6292
|
+
patched: `}
|
|
6293
|
+
|
|
6294
|
+
/** One MCP server the owning session may connect or expose for the current request. Public metadata only. */
|
|
6295
|
+
export interface McpSelectServer {
|
|
6296
|
+
name: string;
|
|
6297
|
+
level: "user" | "project" | "native" | "unknown";
|
|
6298
|
+
provider: string;
|
|
6299
|
+
transport: "stdio" | "http" | "sse";
|
|
6300
|
+
/** Tool names/descriptions (cached for deferred servers, live for connected ones). Untrusted server text. */
|
|
6301
|
+
tools: Array<{ name: string; description?: string }>;
|
|
6302
|
+
/** Connected servers only: true when per-request selection connected it; manual connects are false. */
|
|
6303
|
+
selected?: boolean;
|
|
6304
|
+
}
|
|
6305
|
+
|
|
6306
|
+
/** What core actually connected for the handlers' \`connect\` names. */
|
|
6307
|
+
export interface McpSelectOutcome {
|
|
6308
|
+
connected: string[];
|
|
6309
|
+
failed: Array<{ name: string; error: string }>;
|
|
6310
|
+
}
|
|
6311
|
+
|
|
6312
|
+
/**
|
|
6313
|
+
* Fired in the session owning its MCP manager when \`mcp.selection\` is \`per-request\`, before each
|
|
6314
|
+
* ordinary prompt or dequeued user batch builds its system prompt. Deferred servers are enabled,
|
|
6315
|
+
* allowed configs not yet connected; handlers return names only and core performs the connect.
|
|
6316
|
+
*/
|
|
6317
|
+
export interface McpSelectEvent {
|
|
6318
|
+
type: "mcp_select";
|
|
6319
|
+
/** Same transformed text as \`before_agent_start\`. */
|
|
6320
|
+
prompt: string;
|
|
6321
|
+
deferred: McpSelectServer[];
|
|
6322
|
+
connected: McpSelectServer[];
|
|
6323
|
+
/** Aborts when the prompt is cancelled or the session is torn down. */
|
|
6324
|
+
signal?: AbortSignal;
|
|
6325
|
+
/** Settles after core's connect step, before \`before_agent_start\` of the same request. */
|
|
6326
|
+
outcome: Promise<McpSelectOutcome>;
|
|
6327
|
+
}
|
|
6328
|
+
|
|
6329
|
+
export type {
|
|
6330
|
+
AgentEndEvent,`,
|
|
6331
|
+
},
|
|
6332
|
+
{
|
|
6333
|
+
file: "src/extensibility/extensions/types.ts",
|
|
6334
|
+
marker: ` | McpSelectEvent`,
|
|
6335
|
+
anchor: ` | BeforeSubagentSpawnEvent
|
|
6336
|
+
| AgentStartEvent`,
|
|
6337
|
+
patched: ` | BeforeSubagentSpawnEvent
|
|
6338
|
+
| McpSelectEvent
|
|
6339
|
+
| AgentStartEvent`,
|
|
6340
|
+
},
|
|
6341
|
+
{
|
|
6342
|
+
file: "src/extensibility/extensions/types.ts",
|
|
6343
|
+
marker: ` /** Deferred server names to connect before this request (union across handlers; unknown names ignored). */`,
|
|
6344
|
+
anchor: ` note?: string;
|
|
6345
|
+
}`,
|
|
6346
|
+
patched: ` note?: string;
|
|
6347
|
+
}
|
|
6348
|
+
|
|
6349
|
+
export interface McpSelectEventResult {
|
|
6350
|
+
/** Deferred server names to connect before this request (union across handlers; unknown names ignored). */
|
|
6351
|
+
connect?: string[];
|
|
6352
|
+
/**
|
|
6353
|
+
* Connected servers whose tools stay active for this request (union across handlers). Connected
|
|
6354
|
+
* servers outside it stay connected but their tools are deactivated. Omit to leave activation unchanged.
|
|
6355
|
+
*/
|
|
6356
|
+
expose?: string[];
|
|
6357
|
+
}`,
|
|
6358
|
+
},
|
|
6359
|
+
{
|
|
6360
|
+
file: "src/extensibility/extensions/types.ts",
|
|
6361
|
+
marker: ` on(event: "mcp_select", handler: ExtensionHandler<McpSelectEvent, McpSelectEventResult>): void;`,
|
|
6362
|
+
anchor: ` ): void;
|
|
6363
|
+
on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;`,
|
|
6364
|
+
patched: ` ): void;
|
|
6365
|
+
on(event: "mcp_select", handler: ExtensionHandler<McpSelectEvent, McpSelectEventResult>): void;
|
|
6366
|
+
on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;`,
|
|
6367
|
+
},
|
|
6368
|
+
{
|
|
6369
|
+
// MCP 선택 연결: mcp_select handler의 connect/expose 합집합. 실패·timeout handler는 아무것도 보태지 않는다.
|
|
6370
|
+
file: "src/extensibility/extensions/runner.ts",
|
|
6371
|
+
marker: ` McpSelectEventResult,`,
|
|
6372
|
+
anchor: ` BeforeSubagentSpawnEventResult,
|
|
6373
|
+
CompactOptions,`,
|
|
6374
|
+
patched: ` BeforeSubagentSpawnEventResult,
|
|
6375
|
+
McpSelectEvent,
|
|
6376
|
+
McpSelectEventResult,
|
|
6377
|
+
CompactOptions,`,
|
|
6378
|
+
},
|
|
6379
|
+
{
|
|
6380
|
+
file: "src/extensibility/extensions/runner.ts",
|
|
6381
|
+
marker: ` async emitMcpSelect(event: McpSelectEvent, signal?: AbortSignal): Promise<McpSelectEventResult | undefined> {`,
|
|
6382
|
+
anchor: ` return chosen;
|
|
6383
|
+
}
|
|
6384
|
+
}
|
|
6385
|
+
`,
|
|
6386
|
+
patched: ` return chosen;
|
|
6387
|
+
}
|
|
6388
|
+
|
|
6389
|
+
/**
|
|
6390
|
+
* Runs \`mcp_select\` handlers; \`connect\` and \`expose\` are unions across handlers. A throwing or
|
|
6391
|
+
* timed-out handler contributes nothing, so no handler means no new connection.
|
|
6392
|
+
*/
|
|
6393
|
+
async emitMcpSelect(event: McpSelectEvent, signal?: AbortSignal): Promise<McpSelectEventResult | undefined> {
|
|
6394
|
+
if (!this.hasHandlers("mcp_select")) return undefined;
|
|
6395
|
+
const ctx = this.createContext();
|
|
6396
|
+
const connect = new Set<string>();
|
|
6397
|
+
let expose: Set<string> | undefined;
|
|
6398
|
+
for (const ext of this.extensions) {
|
|
6399
|
+
const handlers = ext.handlers.get("mcp_select");
|
|
6400
|
+
if (!handlers || handlers.length === 0) continue;
|
|
6401
|
+
for (const handler of handlers) {
|
|
6402
|
+
const handlerResult = (await this.#runHandlerWithTimeout(
|
|
6403
|
+
handler,
|
|
6404
|
+
event,
|
|
6405
|
+
ctx,
|
|
6406
|
+
ext,
|
|
6407
|
+
extensionHandlerTimeoutMs,
|
|
6408
|
+
undefined,
|
|
6409
|
+
signal,
|
|
6410
|
+
)) as McpSelectEventResult | undefined;
|
|
6411
|
+
if (!handlerResult) continue;
|
|
6412
|
+
for (const name of handlerResult.connect ?? []) connect.add(name);
|
|
6413
|
+
if (handlerResult.expose) {
|
|
6414
|
+
expose ??= new Set();
|
|
6415
|
+
for (const name of handlerResult.expose) expose.add(name);
|
|
6416
|
+
}
|
|
6417
|
+
}
|
|
6418
|
+
}
|
|
6419
|
+
return { connect: [...connect], expose: expose ? [...expose] : undefined };
|
|
6420
|
+
}
|
|
6421
|
+
}
|
|
6422
|
+
`,
|
|
6423
|
+
},
|
|
5497
6424
|
];
|
|
5498
6425
|
// EDITS 문자열의 줄 끝을 LF로 통일한다. 이 파일의 작업 사본이 CRLF여도 core 파일(LF)과
|
|
5499
6426
|
// 비교·치환이 어긋나지 않는다. core 파일 자체의 줄 끝은 건드리지 않는다.
|