@kolisachint/hoocode-agent 0.5.13 → 0.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/config.d.ts +9 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +16 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/core/context-files.d.ts +16 -3
  7. package/dist/core/context-files.d.ts.map +1 -1
  8. package/dist/core/context-files.js +77 -6
  9. package/dist/core/context-files.js.map +1 -1
  10. package/dist/core/extensions/loader.d.ts +2 -5
  11. package/dist/core/extensions/loader.d.ts.map +1 -1
  12. package/dist/core/extensions/loader.js +5 -0
  13. package/dist/core/extensions/loader.js.map +1 -1
  14. package/dist/core/extensions/types.d.ts +15 -4
  15. package/dist/core/extensions/types.d.ts.map +1 -1
  16. package/dist/core/extensions/types.js.map +1 -1
  17. package/dist/core/learn/digest.d.ts +17 -0
  18. package/dist/core/learn/digest.d.ts.map +1 -0
  19. package/dist/core/learn/digest.js +131 -0
  20. package/dist/core/learn/digest.js.map +1 -0
  21. package/dist/core/learn/extract.d.ts +178 -0
  22. package/dist/core/learn/extract.d.ts.map +1 -0
  23. package/dist/core/learn/extract.js +705 -0
  24. package/dist/core/learn/extract.js.map +1 -0
  25. package/dist/core/learn/normalize.d.ts +65 -0
  26. package/dist/core/learn/normalize.d.ts.map +1 -0
  27. package/dist/core/learn/normalize.js +245 -0
  28. package/dist/core/learn/normalize.js.map +1 -0
  29. package/dist/core/learn/state.d.ts +115 -0
  30. package/dist/core/learn/state.d.ts.map +1 -0
  31. package/dist/core/learn/state.js +151 -0
  32. package/dist/core/learn/state.js.map +1 -0
  33. package/dist/core/settings-defaults.d.ts +5 -0
  34. package/dist/core/settings-defaults.d.ts.map +1 -1
  35. package/dist/core/settings-defaults.js +5 -0
  36. package/dist/core/settings-defaults.js.map +1 -1
  37. package/dist/core/settings-manager.d.ts +14 -0
  38. package/dist/core/settings-manager.d.ts.map +1 -1
  39. package/dist/core/settings-manager.js +17 -0
  40. package/dist/core/settings-manager.js.map +1 -1
  41. package/dist/core/settings-types.d.ts +5 -0
  42. package/dist/core/settings-types.d.ts.map +1 -1
  43. package/dist/core/settings-types.js.map +1 -1
  44. package/dist/extensions/core/hoo-core.d.ts.map +1 -1
  45. package/dist/extensions/core/hoo-core.js +2 -0
  46. package/dist/extensions/core/hoo-core.js.map +1 -1
  47. package/dist/extensions/core/learn.d.ts +22 -0
  48. package/dist/extensions/core/learn.d.ts.map +1 -0
  49. package/dist/extensions/core/learn.js +167 -0
  50. package/dist/extensions/core/learn.js.map +1 -0
  51. package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
  52. package/dist/extensions/core/mcp-loader.js +38 -2
  53. package/dist/extensions/core/mcp-loader.js.map +1 -1
  54. package/dist/main.d.ts.map +1 -1
  55. package/dist/main.js +8 -4
  56. package/dist/main.js.map +1 -1
  57. package/dist/modes/interactive/resource-display.d.ts.map +1 -1
  58. package/dist/modes/interactive/resource-display.js +11 -2
  59. package/dist/modes/interactive/resource-display.js.map +1 -1
  60. package/docs/settings.md +31 -0
  61. package/docs/usage.md +64 -2
  62. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  63. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  64. package/examples/extensions/sandbox/package.json +1 -1
  65. package/examples/extensions/with-deps/package.json +1 -1
  66. package/package.json +4 -4
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `/learn` — promote what recent sessions actually taught into durable rules
3
+ * and skills.
4
+ *
5
+ * The command is a thin shell on purpose. It runs the deterministic extractor
6
+ * over session transcripts on disk, renders the ranked result, and injects it
7
+ * as a follow-up message; every judgement after that belongs to the model,
8
+ * which can read the repo and phrase a rule far better than a heuristic can.
9
+ *
10
+ * Reading transcripts from disk rather than the live context is what makes this
11
+ * work: the on-disk history survives compaction, and it spans past sessions, so
12
+ * "you have said this in five separate sessions" is available as a number
13
+ * instead of a guess. That number is the whole reason the command exists.
14
+ *
15
+ * Follows /grill in modes.ts: no session switch, no mode change, no config
16
+ * write — just a follow-up message. Writes to AGENTS.md happen through ordinary
17
+ * edit tools, so the existing permission prompt is the approval step and no
18
+ * separate picker is needed.
19
+ */
20
+ import type { ExtensionAPI } from "../../core/extensions/types.js";
21
+ export declare function setupLearn(pi: ExtensionAPI): void;
22
+ //# sourceMappingURL=learn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"learn.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/learn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAMH,OAAO,KAAK,EAAE,YAAY,EAA2B,MAAM,gCAAgC,CAAC;AAwF5F,wBAAgB,UAAU,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAwFjD","sourcesContent":["/**\n * `/learn` — promote what recent sessions actually taught into durable rules\n * and skills.\n *\n * The command is a thin shell on purpose. It runs the deterministic extractor\n * over session transcripts on disk, renders the ranked result, and injects it\n * as a follow-up message; every judgement after that belongs to the model,\n * which can read the repo and phrase a rule far better than a heuristic can.\n *\n * Reading transcripts from disk rather than the live context is what makes this\n * work: the on-disk history survives compaction, and it spans past sessions, so\n * \"you have said this in five separate sessions\" is available as a number\n * instead of a guess. That number is the whole reason the command exists.\n *\n * Follows /grill in modes.ts: no session switch, no mode change, no config\n * write — just a follow-up message. Writes to AGENTS.md happen through ordinary\n * edit tools, so the existing permission prompt is the approval step and no\n * separate picker is needed.\n */\n\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { getHooCodeDir } from \"../../config.js\";\nimport { loadProjectContextFiles } from \"../../core/context-files.js\";\nimport type { ExtensionAPI, ExtensionCommandContext } from \"../../core/extensions/types.js\";\nimport { isEmptyDigest, renderLearnDigest } from \"../../core/learn/digest.js\";\nimport { buildCoverageIndex, extractLearnDigest, matchCoverage } from \"../../core/learn/extract.js\";\nimport {\n\tgetLearnStatePath,\n\treadLearnState,\n\trecordSurfaced,\n\tsummarizeLearnState,\n\twriteLearnState,\n} from \"../../core/learn/state.js\";\nimport { SettingsManager } from \"../../core/settings-manager.js\";\n\n/** Guards against double-registration when default extensions load more than once. */\nconst REGISTERED = Symbol.for(\"hoocode.learn.registered\");\n\n/** User-scope destination offered for personal rules that travel across repos. */\nconst USER_SCOPE_PATH = join(homedir(), \".agents\", \"AGENTS.md\");\n\n/** Render a home-relative path the way the user would type it. */\nfunction displayPath(path: string): string {\n\tconst home = homedir();\n\treturn path.startsWith(home) ? `~${path.slice(home.length)}` : path;\n}\n\nfunction shortDate(iso: string | undefined): string {\n\tif (!iso) return \"unknown\";\n\tconst date = new Date(iso);\n\treturn Number.isNaN(date.getTime()) ? \"unknown\" : date.toISOString().slice(0, 10);\n}\n\n/**\n * `/learn stats` — what became of past proposals.\n *\n * Reads the state file and recomputes coverage; it does not re-mine sessions,\n * so it is instant and answers a different question than a normal run: not\n * \"what should I write down\" but \"is this command earning its place\".\n */\nfunction reportStats(ctx: ExtensionCommandContext): void {\n\tconst agentDir = getHooCodeDir();\n\tconst statePath = getLearnStatePath(agentDir, ctx.sessionManager.getSessionDir());\n\tconst state = readLearnState(statePath);\n\n\tif (Object.keys(state.surfaced).length === 0) {\n\t\tctx.ui.notify(\"No /learn history for this directory yet.\", \"info\");\n\t\treturn;\n\t}\n\n\tconst coverage = buildCoverageIndex({ cwd: ctx.cwd, agentDir });\n\tconst stats = summarizeLearnState(state, (normalized) => {\n\t\tconst match = matchCoverage(normalized, coverage);\n\t\treturn !!(match.rule || match.skill);\n\t});\n\n\tconst contextTokens = loadProjectContextFiles({ cwd: ctx.cwd, agentDir }).agentsFiles.reduce(\n\t\t(sum, file) => sum + (file.tokens ?? 0),\n\t\t0,\n\t);\n\n\tconst lines: string[] = [];\n\tlines.push(`/learn history for this directory — ${shortDate(stats.earliest)} to ${shortDate(stats.latest)}`);\n\tlines.push(\n\t\t` Proposals shown ${stats.total} (${stats.directives} directive, ${stats.fixes} fix, ${stats.workflows} workflow)`,\n\t);\n\tif (stats.lastRun) lines.push(` Last run ${shortDate(stats.lastRun)}`);\n\tlines.push(\"\");\n\n\tif (stats.open === 0) {\n\t\tlines.push(\"No directive proposals yet, so there is nothing to measure adoption against.\");\n\t} else {\n\t\tconst rate = Math.round((stats.adopted / stats.open) * 100);\n\t\tlines.push(\"Directive adoption — the only category with a coverage signal\");\n\t\tlines.push(` Written down ${stats.adopted} of ${stats.open} (${rate}%)`);\n\t\tlines.push(` Passed over ${stats.declined}`);\n\t\tlines.push(\"\");\n\t\t// Without this the number invites the wrong conclusion. Adoption is a proxy\n\t\t// for usefulness, and a proposal correctly rejected as not durable counts\n\t\t// against it exactly like a junk one — so near-100% means the bar is too\n\t\t// low, not that the extractor is perfect.\n\t\tlines.push(\" A very high rate means the bar is too low, not that every proposal was good.\");\n\t\tlines.push(\" Near zero means the extractor is proposing the wrong things.\");\n\t}\n\n\tlines.push(\"\");\n\tlines.push(`Context files ~${contextTokens} tokens, re-sent every request`);\n\n\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n}\n\nexport function setupLearn(pi: ExtensionAPI): void {\n\tconst guarded = pi as unknown as Record<symbol, boolean>;\n\tif (guarded[REGISTERED]) return;\n\tguarded[REGISTERED] = true;\n\n\tpi.registerCommand(\"learn\", {\n\t\tdescription: \"Mine recent sessions for durable rules and skills. Usage: /learn [all|stats]\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\t(\n\t\t\t\t[\n\t\t\t\t\t{ value: \"all\", label: \"re-propose everything\" },\n\t\t\t\t\t{ value: \"stats\", label: \"what happened to past proposals\" },\n\t\t\t\t] as const\n\t\t\t)\n\t\t\t\t.filter((option) => option.value.startsWith(prefix))\n\t\t\t\t.map((option) => ({ value: option.value, label: option.label })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst argument = args.trim().toLowerCase();\n\t\t\tif (argument && argument !== \"all\" && argument !== \"stats\") {\n\t\t\t\tctx.ui.notify(\"Usage: /learn [all|stats]\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (argument === \"stats\") {\n\t\t\t\treportStats(ctx);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst ignoreState = argument === \"all\";\n\n\t\t\t// Read per-invocation so a settings edit takes effect without a reload,\n\t\t\t// and so a project settings.json can narrow the window for one repo.\n\t\t\tconst agentDir = getHooCodeDir();\n\t\t\tconst window = SettingsManager.create(ctx.cwd, agentDir).getLearnSettings();\n\t\t\tconst sessionDir = ctx.sessionManager.getSessionDir();\n\t\t\tconst statePath = getLearnStatePath(agentDir, sessionDir);\n\n\t\t\tlet digest: ReturnType<typeof extractLearnDigest>;\n\t\t\ttry {\n\t\t\t\tdigest = extractLearnDigest({\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\tagentDir,\n\t\t\t\t\t// The live session manager already knows where this cwd's sessions\n\t\t\t\t\t// live, which avoids re-deriving (and re-creating) the directory.\n\t\t\t\t\tsessionDir,\n\t\t\t\t\tmaxSessions: window.maxSessions,\n\t\t\t\t\tmaxAgeDays: window.maxAgeDays,\n\t\t\t\t\tminRepeats: window.minRepeats,\n\t\t\t\t\tminWorkflowRepeats: window.minWorkflowRepeats,\n\t\t\t\t\tmaxProposals: window.maxProposals,\n\t\t\t\t\tstate: readLearnState(statePath),\n\t\t\t\t\tignoreState,\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tctx.ui.notify(`/learn could not read session history: ${error}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (digest.scannedSessions === 0) {\n\t\t\t\tctx.ui.notify(\"No recent sessions in this directory to learn from.\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (isEmptyDigest(digest)) {\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\tdigest.suppressed > 0\n\t\t\t\t\t\t? `Scanned ${digest.scannedSessions} session(s) — nothing new since last time (${digest.suppressed} already shown). Run /learn all to see them again.`\n\t\t\t\t\t\t: `Scanned ${digest.scannedSessions} session(s) — nothing repeated often enough to be worth a rule yet.`,\n\t\t\t\t\t\"info\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst counts = [\n\t\t\t\tdigest.directives.length > 0 ? `${digest.directives.length} directive(s)` : undefined,\n\t\t\t\tdigest.fixes.length > 0 ? `${digest.fixes.length} fix(es)` : undefined,\n\t\t\t\tdigest.workflows.length > 0 ? `${digest.workflows.length} workflow(s)` : undefined,\n\t\t\t].filter((part): part is string => !!part);\n\t\t\tconst held = digest.suppressed > 0 ? `, ${digest.suppressed} held back` : \"\";\n\t\t\tctx.ui.notify(`Mined ${digest.scannedSessions} session(s): ${counts.join(\", \")}${held}.`, \"info\");\n\n\t\t\t// Record before delivering: what matters is that these were put in front\n\t\t\t// of the user, which is true whether or not they act on the digest.\n\t\t\twriteLearnState(statePath, recordSurfaced(readLearnState(statePath), digest.surfaced));\n\n\t\t\tpi.sendUserMessage(renderLearnDigest(digest, { userScopePath: displayPath(USER_SCOPE_PATH) }), {\n\t\t\t\tdeliverAs: \"followUp\",\n\t\t\t});\n\t\t},\n\t});\n}\n"]}
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `/learn` — promote what recent sessions actually taught into durable rules
3
+ * and skills.
4
+ *
5
+ * The command is a thin shell on purpose. It runs the deterministic extractor
6
+ * over session transcripts on disk, renders the ranked result, and injects it
7
+ * as a follow-up message; every judgement after that belongs to the model,
8
+ * which can read the repo and phrase a rule far better than a heuristic can.
9
+ *
10
+ * Reading transcripts from disk rather than the live context is what makes this
11
+ * work: the on-disk history survives compaction, and it spans past sessions, so
12
+ * "you have said this in five separate sessions" is available as a number
13
+ * instead of a guess. That number is the whole reason the command exists.
14
+ *
15
+ * Follows /grill in modes.ts: no session switch, no mode change, no config
16
+ * write — just a follow-up message. Writes to AGENTS.md happen through ordinary
17
+ * edit tools, so the existing permission prompt is the approval step and no
18
+ * separate picker is needed.
19
+ */
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { getHooCodeDir } from "../../config.js";
23
+ import { loadProjectContextFiles } from "../../core/context-files.js";
24
+ import { isEmptyDigest, renderLearnDigest } from "../../core/learn/digest.js";
25
+ import { buildCoverageIndex, extractLearnDigest, matchCoverage } from "../../core/learn/extract.js";
26
+ import { getLearnStatePath, readLearnState, recordSurfaced, summarizeLearnState, writeLearnState, } from "../../core/learn/state.js";
27
+ import { SettingsManager } from "../../core/settings-manager.js";
28
+ /** Guards against double-registration when default extensions load more than once. */
29
+ const REGISTERED = Symbol.for("hoocode.learn.registered");
30
+ /** User-scope destination offered for personal rules that travel across repos. */
31
+ const USER_SCOPE_PATH = join(homedir(), ".agents", "AGENTS.md");
32
+ /** Render a home-relative path the way the user would type it. */
33
+ function displayPath(path) {
34
+ const home = homedir();
35
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
36
+ }
37
+ function shortDate(iso) {
38
+ if (!iso)
39
+ return "unknown";
40
+ const date = new Date(iso);
41
+ return Number.isNaN(date.getTime()) ? "unknown" : date.toISOString().slice(0, 10);
42
+ }
43
+ /**
44
+ * `/learn stats` — what became of past proposals.
45
+ *
46
+ * Reads the state file and recomputes coverage; it does not re-mine sessions,
47
+ * so it is instant and answers a different question than a normal run: not
48
+ * "what should I write down" but "is this command earning its place".
49
+ */
50
+ function reportStats(ctx) {
51
+ const agentDir = getHooCodeDir();
52
+ const statePath = getLearnStatePath(agentDir, ctx.sessionManager.getSessionDir());
53
+ const state = readLearnState(statePath);
54
+ if (Object.keys(state.surfaced).length === 0) {
55
+ ctx.ui.notify("No /learn history for this directory yet.", "info");
56
+ return;
57
+ }
58
+ const coverage = buildCoverageIndex({ cwd: ctx.cwd, agentDir });
59
+ const stats = summarizeLearnState(state, (normalized) => {
60
+ const match = matchCoverage(normalized, coverage);
61
+ return !!(match.rule || match.skill);
62
+ });
63
+ const contextTokens = loadProjectContextFiles({ cwd: ctx.cwd, agentDir }).agentsFiles.reduce((sum, file) => sum + (file.tokens ?? 0), 0);
64
+ const lines = [];
65
+ lines.push(`/learn history for this directory — ${shortDate(stats.earliest)} to ${shortDate(stats.latest)}`);
66
+ lines.push(` Proposals shown ${stats.total} (${stats.directives} directive, ${stats.fixes} fix, ${stats.workflows} workflow)`);
67
+ if (stats.lastRun)
68
+ lines.push(` Last run ${shortDate(stats.lastRun)}`);
69
+ lines.push("");
70
+ if (stats.open === 0) {
71
+ lines.push("No directive proposals yet, so there is nothing to measure adoption against.");
72
+ }
73
+ else {
74
+ const rate = Math.round((stats.adopted / stats.open) * 100);
75
+ lines.push("Directive adoption — the only category with a coverage signal");
76
+ lines.push(` Written down ${stats.adopted} of ${stats.open} (${rate}%)`);
77
+ lines.push(` Passed over ${stats.declined}`);
78
+ lines.push("");
79
+ // Without this the number invites the wrong conclusion. Adoption is a proxy
80
+ // for usefulness, and a proposal correctly rejected as not durable counts
81
+ // against it exactly like a junk one — so near-100% means the bar is too
82
+ // low, not that the extractor is perfect.
83
+ lines.push(" A very high rate means the bar is too low, not that every proposal was good.");
84
+ lines.push(" Near zero means the extractor is proposing the wrong things.");
85
+ }
86
+ lines.push("");
87
+ lines.push(`Context files ~${contextTokens} tokens, re-sent every request`);
88
+ ctx.ui.notify(lines.join("\n"), "info");
89
+ }
90
+ export function setupLearn(pi) {
91
+ const guarded = pi;
92
+ if (guarded[REGISTERED])
93
+ return;
94
+ guarded[REGISTERED] = true;
95
+ pi.registerCommand("learn", {
96
+ description: "Mine recent sessions for durable rules and skills. Usage: /learn [all|stats]",
97
+ getArgumentCompletions: (prefix) => [
98
+ { value: "all", label: "re-propose everything" },
99
+ { value: "stats", label: "what happened to past proposals" },
100
+ ]
101
+ .filter((option) => option.value.startsWith(prefix))
102
+ .map((option) => ({ value: option.value, label: option.label })),
103
+ handler: async (args, ctx) => {
104
+ const argument = args.trim().toLowerCase();
105
+ if (argument && argument !== "all" && argument !== "stats") {
106
+ ctx.ui.notify("Usage: /learn [all|stats]", "warning");
107
+ return;
108
+ }
109
+ if (argument === "stats") {
110
+ reportStats(ctx);
111
+ return;
112
+ }
113
+ const ignoreState = argument === "all";
114
+ // Read per-invocation so a settings edit takes effect without a reload,
115
+ // and so a project settings.json can narrow the window for one repo.
116
+ const agentDir = getHooCodeDir();
117
+ const window = SettingsManager.create(ctx.cwd, agentDir).getLearnSettings();
118
+ const sessionDir = ctx.sessionManager.getSessionDir();
119
+ const statePath = getLearnStatePath(agentDir, sessionDir);
120
+ let digest;
121
+ try {
122
+ digest = extractLearnDigest({
123
+ cwd: ctx.cwd,
124
+ agentDir,
125
+ // The live session manager already knows where this cwd's sessions
126
+ // live, which avoids re-deriving (and re-creating) the directory.
127
+ sessionDir,
128
+ maxSessions: window.maxSessions,
129
+ maxAgeDays: window.maxAgeDays,
130
+ minRepeats: window.minRepeats,
131
+ minWorkflowRepeats: window.minWorkflowRepeats,
132
+ maxProposals: window.maxProposals,
133
+ state: readLearnState(statePath),
134
+ ignoreState,
135
+ });
136
+ }
137
+ catch (error) {
138
+ ctx.ui.notify(`/learn could not read session history: ${error}`, "error");
139
+ return;
140
+ }
141
+ if (digest.scannedSessions === 0) {
142
+ ctx.ui.notify("No recent sessions in this directory to learn from.", "warning");
143
+ return;
144
+ }
145
+ if (isEmptyDigest(digest)) {
146
+ ctx.ui.notify(digest.suppressed > 0
147
+ ? `Scanned ${digest.scannedSessions} session(s) — nothing new since last time (${digest.suppressed} already shown). Run /learn all to see them again.`
148
+ : `Scanned ${digest.scannedSessions} session(s) — nothing repeated often enough to be worth a rule yet.`, "info");
149
+ return;
150
+ }
151
+ const counts = [
152
+ digest.directives.length > 0 ? `${digest.directives.length} directive(s)` : undefined,
153
+ digest.fixes.length > 0 ? `${digest.fixes.length} fix(es)` : undefined,
154
+ digest.workflows.length > 0 ? `${digest.workflows.length} workflow(s)` : undefined,
155
+ ].filter((part) => !!part);
156
+ const held = digest.suppressed > 0 ? `, ${digest.suppressed} held back` : "";
157
+ ctx.ui.notify(`Mined ${digest.scannedSessions} session(s): ${counts.join(", ")}${held}.`, "info");
158
+ // Record before delivering: what matters is that these were put in front
159
+ // of the user, which is true whether or not they act on the digest.
160
+ writeLearnState(statePath, recordSurfaced(readLearnState(statePath), digest.surfaced));
161
+ pi.sendUserMessage(renderLearnDigest(digest, { userScopePath: displayPath(USER_SCOPE_PATH) }), {
162
+ deliverAs: "followUp",
163
+ });
164
+ },
165
+ });
166
+ }
167
+ //# sourceMappingURL=learn.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"learn.js","sourceRoot":"","sources":["../../../src/extensions/core/learn.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `/learn` — promote what recent sessions actually taught into durable rules\n * and skills.\n *\n * The command is a thin shell on purpose. It runs the deterministic extractor\n * over session transcripts on disk, renders the ranked result, and injects it\n * as a follow-up message; every judgement after that belongs to the model,\n * which can read the repo and phrase a rule far better than a heuristic can.\n *\n * Reading transcripts from disk rather than the live context is what makes this\n * work: the on-disk history survives compaction, and it spans past sessions, so\n * \"you have said this in five separate sessions\" is available as a number\n * instead of a guess. That number is the whole reason the command exists.\n *\n * Follows /grill in modes.ts: no session switch, no mode change, no config\n * write — just a follow-up message. Writes to AGENTS.md happen through ordinary\n * edit tools, so the existing permission prompt is the approval step and no\n * separate picker is needed.\n */\n\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { getHooCodeDir } from \"../../config.js\";\nimport { loadProjectContextFiles } from \"../../core/context-files.js\";\nimport type { ExtensionAPI, ExtensionCommandContext } from \"../../core/extensions/types.js\";\nimport { isEmptyDigest, renderLearnDigest } from \"../../core/learn/digest.js\";\nimport { buildCoverageIndex, extractLearnDigest, matchCoverage } from \"../../core/learn/extract.js\";\nimport {\n\tgetLearnStatePath,\n\treadLearnState,\n\trecordSurfaced,\n\tsummarizeLearnState,\n\twriteLearnState,\n} from \"../../core/learn/state.js\";\nimport { SettingsManager } from \"../../core/settings-manager.js\";\n\n/** Guards against double-registration when default extensions load more than once. */\nconst REGISTERED = Symbol.for(\"hoocode.learn.registered\");\n\n/** User-scope destination offered for personal rules that travel across repos. */\nconst USER_SCOPE_PATH = join(homedir(), \".agents\", \"AGENTS.md\");\n\n/** Render a home-relative path the way the user would type it. */\nfunction displayPath(path: string): string {\n\tconst home = homedir();\n\treturn path.startsWith(home) ? `~${path.slice(home.length)}` : path;\n}\n\nfunction shortDate(iso: string | undefined): string {\n\tif (!iso) return \"unknown\";\n\tconst date = new Date(iso);\n\treturn Number.isNaN(date.getTime()) ? \"unknown\" : date.toISOString().slice(0, 10);\n}\n\n/**\n * `/learn stats` — what became of past proposals.\n *\n * Reads the state file and recomputes coverage; it does not re-mine sessions,\n * so it is instant and answers a different question than a normal run: not\n * \"what should I write down\" but \"is this command earning its place\".\n */\nfunction reportStats(ctx: ExtensionCommandContext): void {\n\tconst agentDir = getHooCodeDir();\n\tconst statePath = getLearnStatePath(agentDir, ctx.sessionManager.getSessionDir());\n\tconst state = readLearnState(statePath);\n\n\tif (Object.keys(state.surfaced).length === 0) {\n\t\tctx.ui.notify(\"No /learn history for this directory yet.\", \"info\");\n\t\treturn;\n\t}\n\n\tconst coverage = buildCoverageIndex({ cwd: ctx.cwd, agentDir });\n\tconst stats = summarizeLearnState(state, (normalized) => {\n\t\tconst match = matchCoverage(normalized, coverage);\n\t\treturn !!(match.rule || match.skill);\n\t});\n\n\tconst contextTokens = loadProjectContextFiles({ cwd: ctx.cwd, agentDir }).agentsFiles.reduce(\n\t\t(sum, file) => sum + (file.tokens ?? 0),\n\t\t0,\n\t);\n\n\tconst lines: string[] = [];\n\tlines.push(`/learn history for this directory — ${shortDate(stats.earliest)} to ${shortDate(stats.latest)}`);\n\tlines.push(\n\t\t` Proposals shown ${stats.total} (${stats.directives} directive, ${stats.fixes} fix, ${stats.workflows} workflow)`,\n\t);\n\tif (stats.lastRun) lines.push(` Last run ${shortDate(stats.lastRun)}`);\n\tlines.push(\"\");\n\n\tif (stats.open === 0) {\n\t\tlines.push(\"No directive proposals yet, so there is nothing to measure adoption against.\");\n\t} else {\n\t\tconst rate = Math.round((stats.adopted / stats.open) * 100);\n\t\tlines.push(\"Directive adoption — the only category with a coverage signal\");\n\t\tlines.push(` Written down ${stats.adopted} of ${stats.open} (${rate}%)`);\n\t\tlines.push(` Passed over ${stats.declined}`);\n\t\tlines.push(\"\");\n\t\t// Without this the number invites the wrong conclusion. Adoption is a proxy\n\t\t// for usefulness, and a proposal correctly rejected as not durable counts\n\t\t// against it exactly like a junk one — so near-100% means the bar is too\n\t\t// low, not that the extractor is perfect.\n\t\tlines.push(\" A very high rate means the bar is too low, not that every proposal was good.\");\n\t\tlines.push(\" Near zero means the extractor is proposing the wrong things.\");\n\t}\n\n\tlines.push(\"\");\n\tlines.push(`Context files ~${contextTokens} tokens, re-sent every request`);\n\n\tctx.ui.notify(lines.join(\"\\n\"), \"info\");\n}\n\nexport function setupLearn(pi: ExtensionAPI): void {\n\tconst guarded = pi as unknown as Record<symbol, boolean>;\n\tif (guarded[REGISTERED]) return;\n\tguarded[REGISTERED] = true;\n\n\tpi.registerCommand(\"learn\", {\n\t\tdescription: \"Mine recent sessions for durable rules and skills. Usage: /learn [all|stats]\",\n\t\tgetArgumentCompletions: (prefix: string) =>\n\t\t\t(\n\t\t\t\t[\n\t\t\t\t\t{ value: \"all\", label: \"re-propose everything\" },\n\t\t\t\t\t{ value: \"stats\", label: \"what happened to past proposals\" },\n\t\t\t\t] as const\n\t\t\t)\n\t\t\t\t.filter((option) => option.value.startsWith(prefix))\n\t\t\t\t.map((option) => ({ value: option.value, label: option.label })),\n\t\thandler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {\n\t\t\tconst argument = args.trim().toLowerCase();\n\t\t\tif (argument && argument !== \"all\" && argument !== \"stats\") {\n\t\t\t\tctx.ui.notify(\"Usage: /learn [all|stats]\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (argument === \"stats\") {\n\t\t\t\treportStats(ctx);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst ignoreState = argument === \"all\";\n\n\t\t\t// Read per-invocation so a settings edit takes effect without a reload,\n\t\t\t// and so a project settings.json can narrow the window for one repo.\n\t\t\tconst agentDir = getHooCodeDir();\n\t\t\tconst window = SettingsManager.create(ctx.cwd, agentDir).getLearnSettings();\n\t\t\tconst sessionDir = ctx.sessionManager.getSessionDir();\n\t\t\tconst statePath = getLearnStatePath(agentDir, sessionDir);\n\n\t\t\tlet digest: ReturnType<typeof extractLearnDigest>;\n\t\t\ttry {\n\t\t\t\tdigest = extractLearnDigest({\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\tagentDir,\n\t\t\t\t\t// The live session manager already knows where this cwd's sessions\n\t\t\t\t\t// live, which avoids re-deriving (and re-creating) the directory.\n\t\t\t\t\tsessionDir,\n\t\t\t\t\tmaxSessions: window.maxSessions,\n\t\t\t\t\tmaxAgeDays: window.maxAgeDays,\n\t\t\t\t\tminRepeats: window.minRepeats,\n\t\t\t\t\tminWorkflowRepeats: window.minWorkflowRepeats,\n\t\t\t\t\tmaxProposals: window.maxProposals,\n\t\t\t\t\tstate: readLearnState(statePath),\n\t\t\t\t\tignoreState,\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tctx.ui.notify(`/learn could not read session history: ${error}`, \"error\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (digest.scannedSessions === 0) {\n\t\t\t\tctx.ui.notify(\"No recent sessions in this directory to learn from.\", \"warning\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (isEmptyDigest(digest)) {\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\tdigest.suppressed > 0\n\t\t\t\t\t\t? `Scanned ${digest.scannedSessions} session(s) — nothing new since last time (${digest.suppressed} already shown). Run /learn all to see them again.`\n\t\t\t\t\t\t: `Scanned ${digest.scannedSessions} session(s) — nothing repeated often enough to be worth a rule yet.`,\n\t\t\t\t\t\"info\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst counts = [\n\t\t\t\tdigest.directives.length > 0 ? `${digest.directives.length} directive(s)` : undefined,\n\t\t\t\tdigest.fixes.length > 0 ? `${digest.fixes.length} fix(es)` : undefined,\n\t\t\t\tdigest.workflows.length > 0 ? `${digest.workflows.length} workflow(s)` : undefined,\n\t\t\t].filter((part): part is string => !!part);\n\t\t\tconst held = digest.suppressed > 0 ? `, ${digest.suppressed} held back` : \"\";\n\t\t\tctx.ui.notify(`Mined ${digest.scannedSessions} session(s): ${counts.join(\", \")}${held}.`, \"info\");\n\n\t\t\t// Record before delivering: what matters is that these were put in front\n\t\t\t// of the user, which is true whether or not they act on the digest.\n\t\t\twriteLearnState(statePath, recordSurfaced(readLearnState(statePath), digest.surfaced));\n\n\t\t\tpi.sendUserMessage(renderLearnDigest(digest, { userScopePath: displayPath(USER_SCOPE_PATH) }), {\n\t\t\t\tdeliverAs: \"followUp\",\n\t\t\t});\n\t\t},\n\t});\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-loader.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/mcp-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAiBH,OAAO,KAAK,EAAE,YAAY,EAAuD,MAAM,gCAAgC,CAAC;AA8bxH,wBAAgB,cAAc,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAqRrD","sourcesContent":["/**\n * MCP server loader — discovers server configs (standard mcp.json locations,\n * hoocode's per-server JSON files, plugin registrations), connects via JSON-RPC\n * 2.0 over stdio (`command`), Streamable HTTP (`{ \"type\": \"http\", \"url\": ... }`),\n * or legacy SSE (`\"type\": \"sse\"`), and registers each server tool as\n * `mcp_<server>_<tool>`.\n *\n * Config sources (first-wins by server name):\n * 1. ~/.agents/mcp.json (user), ./.agents/mcp.json (project),\n * ~/.config/claude/mcp.json (Claude Desktop)\n * 2. ~/.hoocode/mcp-servers/*.json and ./.hoocode/mcp-servers/*.json\n * 3. MCP servers registered by plugins/extensions during load\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { readdir } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { createInterface } from \"node:readline\";\nimport type { AgentToolResult, AgentToolUpdateCallback, McpRemoteOptions } from \"@kolisachint/hoocode-agent-core\";\nimport { connectHttpMcpServer, summarizeArgs } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { getHooCodeDir } from \"../../config.js\";\nimport { ensureDenseIndex } from \"../../core/capabilities/dense.js\";\nimport { registerCapabilities } from \"../../core/capabilities/registry.js\";\nimport { resetCapabilitySearch, searchCapabilities } from \"../../core/capabilities/search.js\";\nimport { getExtensionMcpServers } from \"../../core/extension-mcp-servers.js\";\nimport type { ExtensionAPI, ExtensionContext, SessionStartEvent, ToolDefinition } from \"../../core/extensions/types.js\";\nimport { formatDurationSecs } from \"../../core/format-duration.js\";\nimport { clearMcpServerStatuses, setMcpServerStatus } from \"../../core/mcp-status.js\";\nimport { deferMcpSchemas, subagentSkipMcp } from \"../../core/subagent-depth.js\";\nimport { taskStore } from \"../../core/task-store.js\";\nimport {\n\ttype DeferredMcpToolEntry,\n\tformatDeferredCatalog,\n\tselectResolvable,\n\ttoCapabilityDocs,\n} from \"./mcp-deferred.js\";\n\nconst HOOCODE_DIR = getHooCodeDir();\n\ninterface McpToolDetails {\n\telapsed?: number;\n}\n\ninterface McpToolDef {\n\tname: string;\n\tdescription: string;\n\tinputSchema?: {\n\t\ttype?: string;\n\t\tproperties?: Record<string, { type?: string; description?: string }>;\n\t\trequired?: string[];\n\t};\n}\n\ninterface McpServerConfig {\n\t/** Unique server identifier used as prefix for registered tool names */\n\tname: string;\n\t/** Executable to spawn (stdio transport). One of command/url is required. */\n\tcommand?: string;\n\t/** Optional arguments passed to the command */\n\targs?: string[];\n\t/** Optional extra environment variables for the server process */\n\tenv?: Record<string, string>;\n\t/** Transport: \"stdio\" (default with command), \"http\" (Streamable HTTP), or \"sse\" (legacy) */\n\ttype?: \"stdio\" | \"http\" | \"sse\";\n\t/** Remote server URL (http/sse transports) */\n\turl?: string;\n\t/** Extra HTTP headers (e.g. Authorization) for remote transports */\n\theaders?: Record<string, string>;\n\t/** Run MCP tools in background by default (default: true for MCP servers) */\n\tbackground?: boolean;\n}\n\n/** Standard MCP config format used by Claude Desktop, Claude Code, VS Code / Copilot */\ninterface StandardMcpServerConfig {\n\tcommand?: string;\n\targs?: string[];\n\tenv?: Record<string, string>;\n\ttype?: \"stdio\" | \"http\" | \"sse\";\n\turl?: string;\n\theaders?: Record<string, string>;\n\tbackground?: boolean;\n}\n\ninterface StandardMcpConfig {\n\tmcpServers?: Record<string, StandardMcpServerConfig>;\n}\n\ninterface McpConnection {\n\trpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;\n\t/** Send a JSON-RPC notification (no id, no response expected). */\n\tnotify(method: string, params?: unknown): void;\n\tterminate(): void;\n}\n\nconst mcpConnections = new Map<string, McpConnection>();\n/**\n * Server configs retained by name so a tool call can transparently reconnect a\n * dropped server (process churn between turns, server exit, a racing teardown)\n * instead of permanently failing with \"not connected\".\n */\nconst mcpServerConfigs = new Map<string, McpServerConfig>();\n/** Remote-transport options (OAuth storage, auth callbacks) per server name. */\nconst mcpRemoteOptions = new Map<string, McpRemoteOptions>();\n/**\n * Servers with a browser authorization flow in flight. While a name is here,\n * a failed handshake must NOT tear the connection down (that would kill the\n * OAuth loopback listener); the auth-completion handler reconnects instead.\n */\nconst mcpAuthPending = new Set<string>();\n\n/** Timeout for the connection handshake (initialize / tools/list). Tool calls\n * themselves are left untimed since MCP tools can be long-running. */\nconst MCP_HANDSHAKE_TIMEOUT_MS = 15000;\n\nfunction spawnMcpServer(config: McpServerConfig & { command: string }): McpConnection {\n\tconst proc: ChildProcess = spawn(config.command, config.args ?? [], {\n\t\tenv: { ...process.env, ...(config.env ?? {}) },\n\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t});\n\n\tlet nextId = 1;\n\tconst pending = new Map<number, { resolve: (r: unknown) => void; reject: (e: Error) => void }>();\n\n\tconst rl = createInterface({ input: proc.stdout! });\n\trl.on(\"line\", (line) => {\n\t\tif (!line.trim()) return;\n\t\ttry {\n\t\t\tconst msg = JSON.parse(line) as {\n\t\t\t\tid?: number;\n\t\t\t\tresult?: unknown;\n\t\t\t\terror?: { message: string };\n\t\t\t};\n\t\t\tif (msg.id === undefined) return;\n\t\t\tconst cb = pending.get(msg.id);\n\t\t\tif (!cb) return;\n\t\t\tpending.delete(msg.id);\n\t\t\tif (msg.error) cb.reject(new Error(msg.error.message));\n\t\t\telse cb.resolve(msg.result);\n\t\t} catch {\n\t\t\t// ignore non-JSON server startup output\n\t\t}\n\t});\n\n\tproc.on(\"exit\", () => {\n\t\tfor (const cb of pending.values()) cb.reject(new Error(`MCP server \"${config.name}\" exited unexpectedly`));\n\t\tpending.clear();\n\t\tmcpConnections.delete(config.name);\n\t});\n\n\tfunction rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {\n\t\tconst id = nextId++;\n\t\treturn new Promise<unknown>((resolve, reject) => {\n\t\t\tlet timer: NodeJS.Timeout | undefined;\n\t\t\tif (timeoutMs && timeoutMs > 0) {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tif (pending.delete(id)) {\n\t\t\t\t\t\treject(new Error(`MCP server \"${config.name}\" timed out after ${timeoutMs}ms on ${method}`));\n\t\t\t\t\t}\n\t\t\t\t}, timeoutMs);\n\t\t\t\ttimer.unref?.();\n\t\t\t}\n\t\t\tpending.set(id, {\n\t\t\t\tresolve: (r) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tresolve(r);\n\t\t\t\t},\n\t\t\t\treject: (e) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\treject(e);\n\t\t\t\t},\n\t\t\t});\n\t\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", id, method, params })}\\n`);\n\t\t});\n\t}\n\n\tfunction notify(method: string, params?: unknown): void {\n\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", method, params })}\\n`);\n\t}\n\n\treturn {\n\t\trpc,\n\t\tnotify,\n\t\tterminate: () => {\n\t\t\trl.close();\n\t\t\tproc.kill();\n\t\t},\n\t};\n}\n\n/** Remote servers use `{ type: \"http\" | \"sse\", url }`; stdio servers use `command`. */\nfunction isRemoteServer(config: McpServerConfig): boolean {\n\treturn config.type === \"http\" || config.type === \"sse\" || (!config.command && typeof config.url === \"string\");\n}\n\nfunction openMcpConnection(config: McpServerConfig): McpConnection {\n\tif (isRemoteServer(config)) {\n\t\tif (typeof config.url !== \"string\") {\n\t\t\tthrow new Error(`MCP server \"${config.name}\" has type \"${config.type}\" but no \"url\"`);\n\t\t}\n\t\treturn connectHttpMcpServer(\n\t\t\t{\n\t\t\t\tname: config.name,\n\t\t\t\turl: config.url,\n\t\t\t\theaders: config.headers,\n\t\t\t\ttype: config.type === \"sse\" ? \"sse\" : \"http\",\n\t\t\t},\n\t\t\tmcpRemoteOptions.get(config.name),\n\t\t);\n\t}\n\tif (typeof config.command !== \"string\") {\n\t\tthrow new Error(`MCP server \"${config.name}\" needs a \"command\" (stdio) or a \"url\" (http/sse)`);\n\t}\n\treturn spawnMcpServer(config as McpServerConfig & { command: string });\n}\n\nasync function connectMcpServer(config: McpServerConfig): Promise<{ conn: McpConnection; tools: McpToolDef[] }> {\n\tmcpConnections.get(config.name)?.terminate();\n\n\tconst conn = openMcpConnection(config);\n\tmcpConnections.set(config.name, conn);\n\n\ttry {\n\t\tawait conn.rpc(\n\t\t\t\"initialize\",\n\t\t\t{\n\t\t\t\tprotocolVersion: \"2024-11-05\",\n\t\t\t\tcapabilities: { tools: {} },\n\t\t\t\tclientInfo: { name: \"hoocode\", version: \"1.0.0\" },\n\t\t\t},\n\t\t\tMCP_HANDSHAKE_TIMEOUT_MS,\n\t\t);\n\n\t\t// Per the MCP spec the client must acknowledge a successful initialize with the\n\t\t// initialized notification before issuing further requests; strict servers gate\n\t\t// tools/call on it.\n\t\tconn.notify(\"notifications/initialized\");\n\n\t\tconst toolsResult = (await conn.rpc(\"tools/list\", {}, MCP_HANDSHAKE_TIMEOUT_MS)) as {\n\t\t\ttools?: McpToolDef[];\n\t\t};\n\t\treturn { conn, tools: toolsResult.tools ?? [] };\n\t} catch (error) {\n\t\t// Don't leave a half-connected entry behind: remote transports have no\n\t\t// process-exit event to evict them, so getOrConnectMcp would keep handing\n\t\t// out the dead connection instead of reconnecting. Exception: a pending\n\t\t// browser authorization — terminating would kill the OAuth loopback\n\t\t// listener; the auth-completion handler reconnects that server itself.\n\t\tif (!mcpAuthPending.has(config.name)) {\n\t\t\tconn.terminate();\n\t\t\tif (mcpConnections.get(config.name) === conn) mcpConnections.delete(config.name);\n\t\t}\n\t\tthrow error;\n\t}\n}\n\n/**\n * Return a live connection for a server, lazily reconnecting from the retained\n * config when the previous connection was torn down. Returns undefined only when\n * no config is known or a fresh connect attempt fails.\n */\nasync function getOrConnectMcp(name: string): Promise<McpConnection | undefined> {\n\tconst existing = mcpConnections.get(name);\n\tif (existing) return existing;\n\tconst config = mcpServerConfigs.get(name);\n\tif (!config) return undefined;\n\ttry {\n\t\tconst { conn } = await connectMcpServer(config);\n\t\treturn conn;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nlet mcpExitCleanupInstalled = false;\n/** Kill spawned MCP servers when the host process exits so they don't linger as\n * orphans (their stdin merely goes idle, which doesn't terminate them). */\nfunction installMcpExitCleanup(): void {\n\tif (mcpExitCleanupInstalled) return;\n\tmcpExitCleanupInstalled = true;\n\tprocess.once(\"exit\", () => {\n\t\tfor (const conn of mcpConnections.values()) {\n\t\t\ttry {\n\t\t\t\tconn.terminate();\n\t\t\t} catch {\n\t\t\t\t// best-effort cleanup\n\t\t\t}\n\t\t}\n\t\tmcpConnections.clear();\n\t});\n}\n\nfunction buildMcpSchema(tool: McpToolDef): ReturnType<typeof Type.Object> {\n\tconst props = tool.inputSchema?.properties ?? {};\n\tconst required = new Set(tool.inputSchema?.required ?? []);\n\tconst shape: Record<string, ReturnType<typeof Type.String>> = {};\n\n\tfor (const [key, prop] of Object.entries(props)) {\n\t\tlet field: ReturnType<typeof Type.String>;\n\t\tswitch (prop.type) {\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\t\tfield = Type.Number({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tcase \"boolean\":\n\t\t\t\tfield = Type.Boolean({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tfield = Type.String({ description: prop.description });\n\t\t}\n\t\tshape[key] = required.has(key) ? field : (Type.Optional(field) as unknown as ReturnType<typeof Type.String>);\n\t}\n\n\treturn Type.Object(shape);\n}\n\n/**\n * Parse standard MCP config format (used by Claude Desktop, VS Code, etc.)\n * into hoocode's McpServerConfig format.\n */\nfunction parseStandardMcpConfig(config: StandardMcpConfig, _source: string): McpServerConfig[] {\n\tif (!config.mcpServers) return [];\n\n\tconst servers: McpServerConfig[] = [];\n\tfor (const [name, serverConfig] of Object.entries(config.mcpServers)) {\n\t\tservers.push({\n\t\t\tname,\n\t\t\tcommand: serverConfig.command,\n\t\t\targs: serverConfig.args,\n\t\t\tenv: serverConfig.env,\n\t\t\ttype: serverConfig.type,\n\t\t\turl: serverConfig.url,\n\t\t\theaders: serverConfig.headers,\n\t\t\tbackground: serverConfig.background,\n\t\t});\n\t}\n\treturn servers;\n}\n\n/**\n * Load MCP servers from a standard mcp.json file.\n * Returns an array of McpServerConfig, or empty array if file doesn't exist or is invalid.\n */\nfunction loadStandardMcpFile(filePath: string): McpServerConfig[] {\n\tif (!existsSync(filePath)) return [];\n\n\ttry {\n\t\tconst content = readFileSync(filePath, \"utf8\");\n\t\tconst config = JSON.parse(content) as StandardMcpConfig;\n\t\treturn parseStandardMcpConfig(config, filePath);\n\t} catch {\n\t\treturn [];\n\t}\n}\n\n/**\n * Build the full {@link ToolDefinition} for one MCP tool — the complete JSON\n * schema plus the connect/execute machinery. Shared by the eager path (register\n * every tool up front) and the deferred path (materialize on resolve), so both\n * produce identical, callable tools.\n */\nfunction buildMcpToolDefinition(serverConfig: McpServerConfig, tool: McpToolDef): ToolDefinition {\n\tconst toolName = `mcp_${serverConfig.name}_${tool.name}`;\n\tconst schema = buildMcpSchema(tool);\n\tconst capturedServer = serverConfig.name;\n\tconst capturedTool = tool.name;\n\t// MCP tools default to background mode since they are external processes with potential high latency\n\tconst isBackground = serverConfig.background !== false;\n\n\treturn {\n\t\tname: toolName,\n\t\tlabel: `[MCP] ${serverConfig.name} › ${tool.name}`,\n\t\tdescription: tool.description,\n\t\tparameters: schema,\n\t\tbackground: isBackground,\n\t\t// Render a clean, prefixed title in chat — `MCP [server › tool] <args>` —\n\t\t// parallel to the subagent `Task [type] <desc>` line. Without this the\n\t\t// ToolExecutionComponent falls back to the raw `mcp_<server>_<tool>` name.\n\t\t// The args summary reuses the same helper as the background start/finish\n\t\t// messages so the chat title stays in sync with them.\n\t\trenderCall(args, theme) {\n\t\t\tconst summary = summarizeArgs((args ?? {}) as Record<string, unknown>);\n\t\t\tconst text =\n\t\t\t\ttheme.fg(\"toolTitle\", theme.bold(\"MCP \")) +\n\t\t\t\ttheme.fg(\"mcp\", `[${capturedServer} › ${capturedTool}]`) +\n\t\t\t\t(summary ? theme.fg(\"dim\", ` ${summary}`) : \"\");\n\t\t\treturn new Text(text, 0, 0);\n\t\t},\n\t\tasync execute(\n\t\t\t_toolCallId: string,\n\t\t\tparams: Static<typeof schema>,\n\t\t\tsignal: AbortSignal,\n\t\t\t_onUpdate: AgentToolUpdateCallback,\n\t\t): Promise<AgentToolResult<McpToolDetails>> {\n\t\t\tconst startTime = Date.now();\n\t\t\t// Background MCP tools get a task store entry so they appear in the task pane.\n\t\t\t// Foreground tools skip this (their result is awaited inline). The server\n\t\t\t// name rides in subagentMode and becomes the row's `[server]` origin tag;\n\t\t\t// the title carries just the tool.\n\t\t\tconst task = isBackground\n\t\t\t\t? taskStore.create(capturedTool, { source: \"mcp\", subagentMode: capturedServer })\n\t\t\t\t: undefined;\n\t\t\tif (task) taskStore.update(task.id, { status: \"in_progress\" });\n\n\t\t\t// Lazily (re)connect: a dropped connection (server exit, process churn\n\t\t\t// between turns, a racing teardown) should transparently reconnect from\n\t\t\t// the retained config rather than permanently fail with \"not connected\".\n\t\t\tconst activeConn = await getOrConnectMcp(capturedServer);\n\t\t\tif (!activeConn) {\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"failed\" });\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\", text: `MCP server \"${capturedServer}\" is not connected (reconnect attempt failed)` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { elapsed: Date.now() - startTime } as McpToolDetails,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst abortPromise = new Promise<never>((_, reject) => {\n\t\t\t\t\tsignal.addEventListener(\"abort\", () => reject(new Error(\"Aborted\")));\n\t\t\t\t});\n\n\t\t\t\tconst result = await Promise.race([\n\t\t\t\t\tactiveConn.rpc(\"tools/call\", { name: capturedTool, arguments: params }),\n\t\t\t\t\tabortPromise,\n\t\t\t\t]);\n\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"done\" });\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: JSON.stringify(result, null, 2) }],\n\t\t\t\t\tdetails: { elapsed: Date.now() - startTime } as McpToolDetails,\n\t\t\t\t};\n\t\t\t} catch (error) {\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"failed\" });\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t\trenderResult(result, _options, theme, context) {\n\t\t\tconst text = result.content\n\t\t\t\t.map((c) => (c.type === \"text\" ? c.text : \"\"))\n\t\t\t\t.filter(Boolean)\n\t\t\t\t.join(\"\\n\");\n\t\t\tif (!text) return new Text(\"\", 0, 0);\n\n\t\t\tconst details = result.details as McpToolDetails | undefined;\n\t\t\tconst elapsed = details?.elapsed;\n\t\t\tconst elapsedText = elapsed ? ` ${formatDurationSecs(elapsed / 1000)}` : \"\";\n\t\t\tconst identity = `${capturedServer} › ${capturedTool}`;\n\n\t\t\t// Use context.isError to determine status (framework sets this when tools throw)\n\t\t\tconst isError = context.isError;\n\t\t\tconst statusGlyph = isError ? \"✗\" : \"✓\";\n\t\t\tconst statusLabel = isError ? \"failed\" : \"done\";\n\t\t\tconst statusColor = isError ? \"error\" : \"success\";\n\n\t\t\tconst spine = (s: string) => theme.fg(\"borderMuted\", s);\n\t\t\tconst header =\n\t\t\t\t`${spine(\"╭\")} ${theme.fg(statusColor, statusGlyph)} ` +\n\t\t\t\t`${theme.bold(theme.fg(statusColor, statusLabel))} ` +\n\t\t\t\t`${theme.fg(\"mcp\", identity)}${elapsedText}`;\n\t\t\tconst body = text\n\t\t\t\t.split(\"\\n\")\n\t\t\t\t.map((line) => `${spine(\"│\")} ${theme.fg(\"toolOutput\", line)}`)\n\t\t\t\t.join(\"\\n\");\n\t\t\treturn new Text(`${header}\\n${body}\\n${spine(\"╰\")}`, 0, 0);\n\t\t},\n\t} as ToolDefinition;\n}\n\nconst RESOLVE_MCP_TOOLS_NAME = \"ResolveMcpTools\";\n\nexport function setupMcpLoader(pi: ExtensionAPI): void {\n\tpi.on(\"session_start\", async (_event: SessionStartEvent, ctx: ExtensionContext) => {\n\t\t// A spawned subagent whose tool allowlist has no MCP tools is told by its\n\t\t// parent to skip server connection entirely (see SUBAGENT_SKIP_MCP_ENV).\n\t\t// Each connect is a ~15s-timeout handshake; doing it for a subagent that can\n\t\t// never call the tools is pure startup latency.\n\t\tif (subagentSkipMcp()) return;\n\n\t\tinstallMcpExitCleanup();\n\t\t// Rebuild the status registry from scratch: a reload or session switch runs\n\t\t// this pass again and the startup summary must not double-count.\n\t\tclearMcpServerStatuses();\n\t\tconst allServerConfigs: McpServerConfig[] = [];\n\t\tconst seenNames = new Set<string>();\n\n\t\t// 1. Load from standard mcp.json locations\n\t\t// User-level: ~/.agents/mcp.json\n\t\tconst userAgentsConfig = loadStandardMcpFile(join(homedir(), \".agents\", \"mcp.json\"));\n\t\tfor (const config of userAgentsConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// Project-level: ./.agents/mcp.json\n\t\tconst projectAgentsConfig = loadStandardMcpFile(join(ctx.cwd, \".agents\", \"mcp.json\"));\n\t\tfor (const config of projectAgentsConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// Claude Desktop: ~/.config/claude/mcp.json\n\t\tconst claudeDesktopConfig = loadStandardMcpFile(join(homedir(), \".config\", \"claude\", \"mcp.json\"));\n\t\tfor (const config of claudeDesktopConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// 2. Load from hoocode's per-server format (existing behavior)\n\t\tconst searchDirs = [join(HOOCODE_DIR, \"mcp-servers\"), join(ctx.cwd, \".hoocode\", \"mcp-servers\")];\n\n\t\tfor (const dir of searchDirs) {\n\t\t\tif (!existsSync(dir)) continue;\n\n\t\t\tlet files: string[];\n\t\t\ttry {\n\t\t\t\tfiles = (await readdir(dir)).filter((f) => f.endsWith(\".json\"));\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tfor (const file of files) {\n\t\t\t\tconst cfgPath = join(dir, file);\n\t\t\t\tlet serverConfig: McpServerConfig;\n\n\t\t\t\ttry {\n\t\t\t\t\tserverConfig = JSON.parse(readFileSync(cfgPath, \"utf8\")) as McpServerConfig;\n\t\t\t\t\tif (!serverConfig.name || (!serverConfig.command && !serverConfig.url)) {\n\t\t\t\t\t\tctx.ui.notify(`MCP: config \"${file}\" is missing required \"name\" or \"command\"/\"url\"`, \"warning\");\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t} catch (err) {\n\t\t\t\t\tctx.ui.notify(`MCP: failed to parse \"${file}\": ${String(err)}`, \"error\");\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// Skip if already loaded from standard config\n\t\t\t\tif (seenNames.has(serverConfig.name)) continue;\n\t\t\t\tseenNames.add(serverConfig.name);\n\t\t\t\tallServerConfigs.push(serverConfig);\n\t\t\t}\n\t\t}\n\n\t\t// 2b. Load from plugins/extensions that registered MCP servers during load.\n\t\tfor (const entry of getExtensionMcpServers()) {\n\t\t\tfor (const serverConfig of parseStandardMcpConfig(\n\t\t\t\t{ mcpServers: entry.mcpServers },\n\t\t\t\t`plugin:${entry.source}`,\n\t\t\t)) {\n\t\t\t\tif (seenNames.has(serverConfig.name)) continue;\n\t\t\t\tseenNames.add(serverConfig.name);\n\t\t\t\tallServerConfigs.push(serverConfig);\n\t\t\t}\n\t\t}\n\n\t\t// Deferral (spec §2): inject MCP tool names only and materialize each schema\n\t\t// on demand via ResolveMcpTools. Default-on and top-level only — a subagent that\n\t\t// needs MCP has this env cleared, so it eager-registers its allowlisted tools\n\t\t// at dispatch (the dispatch ↔ schema interaction) and they are immediately callable.\n\t\tconst defer = deferMcpSchemas();\n\t\tconst deferredCatalog: DeferredMcpToolEntry[] = [];\n\t\t// Retain each deferred tool's raw definition + config so ResolveMcpTools can\n\t\t// build the full ToolDefinition on request.\n\t\tconst deferredByName = new Map<string, { serverConfig: McpServerConfig; tool: McpToolDef }>();\n\t\tconst resolvedNames = new Set<string>();\n\n\t\t// Remote servers get OAuth support: tokens persist under\n\t\t// ~/.hoocode/mcp-auth, and when a server demands interactive (browser)\n\t\t// authorization the handshake fails fast while the flow keeps running in\n\t\t// the background — on completion the server is reconnected and its tools\n\t\t// registered (eagerly, since the deferred catalog is already sealed).\n\t\tconst buildRemoteOptions = (serverConfig: McpServerConfig): McpRemoteOptions => ({\n\t\t\tauthStorageDir: join(HOOCODE_DIR, \"mcp-auth\"),\n\t\t\tonAuthRequired: (authorizationUrl, completed) => {\n\t\t\t\tmcpAuthPending.add(serverConfig.name);\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`MCP: \"${serverConfig.name}\" requires authorization — complete the sign-in opened in your browser` +\n\t\t\t\t\t\t(authorizationUrl ? `: ${authorizationUrl}` : \"\"),\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\tcompleted\n\t\t\t\t\t.then(async () => {\n\t\t\t\t\t\tmcpAuthPending.delete(serverConfig.name);\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst { tools } = await connectMcpServer(serverConfig);\n\t\t\t\t\t\t\tfor (const tool of tools) pi.registerTool(buildMcpToolDefinition(serverConfig, tool));\n\t\t\t\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\t\t\t\ttoolCount: tools.length,\n\t\t\t\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\t\t\t\tdeferred: false,\n\t\t\t\t\t\t\t\tstate: \"connected\",\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t\t\t`MCP: connected \"${serverConfig.name}\" after authorization (${tools.length} tool${tools.length === 1 ? \"\" : \"s\"})`,\n\t\t\t\t\t\t\t\t\"info\",\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t\t\t`MCP: failed to connect \"${serverConfig.name}\" after authorization: ${String(err)}`,\n\t\t\t\t\t\t\t\t\"error\",\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\t\tmcpAuthPending.delete(serverConfig.name);\n\t\t\t\t\t\tctx.ui.notify(`MCP: authorization for \"${serverConfig.name}\" failed: ${String(err)}`, \"error\");\n\t\t\t\t\t});\n\t\t\t},\n\t\t});\n\n\t\t// 3. Connect to all servers and register (or defer) tools\n\t\tfor (const serverConfig of allServerConfigs) {\n\t\t\t// Retain the config so a tool call can lazily reconnect a dropped server.\n\t\t\tmcpServerConfigs.set(serverConfig.name, serverConfig);\n\t\t\tmcpRemoteOptions.set(serverConfig.name, buildRemoteOptions(serverConfig));\n\t\t\ttry {\n\t\t\t\tconst { tools } = await connectMcpServer(serverConfig);\n\n\t\t\t\tfor (const tool of tools) {\n\t\t\t\t\tconst toolName = `mcp_${serverConfig.name}_${tool.name}`;\n\t\t\t\t\tif (defer) {\n\t\t\t\t\t\tdeferredCatalog.push({ toolName, server: serverConfig.name, description: tool.description });\n\t\t\t\t\t\tdeferredByName.set(toolName, { serverConfig, tool });\n\t\t\t\t\t} else {\n\t\t\t\t\t\tpi.registerTool(buildMcpToolDefinition(serverConfig, tool));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// A successful connect is reported by the startup resource summary (one\n\t\t\t\t// `mcp` cell plus a details row), not as a transient line above it.\n\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\ttoolCount: tools.length,\n\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\tdeferred: defer,\n\t\t\t\t\tstate: \"connected\",\n\t\t\t\t});\n\t\t\t} catch (err) {\n\t\t\t\tif (mcpAuthPending.has(serverConfig.name)) {\n\t\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\t\ttoolCount: 0,\n\t\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\t\tdeferred: defer,\n\t\t\t\t\t\tstate: \"authorizing\",\n\t\t\t\t\t});\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`MCP: \"${serverConfig.name}\" is waiting for browser authorization; tools will register once it completes`,\n\t\t\t\t\t\t\"info\",\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tctx.ui.notify(`MCP: failed to connect \"${serverConfig.name}\": ${String(err)}`, \"error\");\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 4. In deferred mode, register the single resolver that materializes schemas on demand.\n\t\tif (defer && deferredCatalog.length > 0) {\n\t\t\t// Feed the capability index. This is what lets the catalog above be\n\t\t\t// summarized instead of dumped: a tool the model cannot see in the\n\t\t\t// description is still reachable by describing what it needs.\n\t\t\tregisterCapabilities(\"mcp-tool\", toCapabilityDocs(deferredCatalog, true));\n\t\t\tresetCapabilitySearch();\n\t\t\t// Fire-and-forget: the lexical leg answers immediately, and the dense one\n\t\t\t// joins when (and if) it is ready. Awaiting here would put an embedding\n\t\t\t// model's startup in front of the session's.\n\t\t\tvoid ensureDenseIndex(toCapabilityDocs(deferredCatalog, true)).catch(() => {});\n\n\t\t\tconst resolveParams = Type.Object(\n\t\t\t\t{\n\t\t\t\t\tnames: Type.Optional(\n\t\t\t\t\t\tType.Array(Type.String(), {\n\t\t\t\t\t\t\tdescription: \"Exact MCP tool names to make callable (e.g. 'mcp_github_create_pr' or 'create_pr').\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t\tquery: Type.Optional(\n\t\t\t\t\t\tType.String({\n\t\t\t\t\t\t\tdescription:\n\t\t\t\t\t\t\t\t\"Describe the capability you need ('open a pull request', 'send a message') to find tools by \" +\n\t\t\t\t\t\t\t\t\"meaning when you do not know their names. Matching tools are resolved and become callable.\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t\tlimit: Type.Optional(Type.Number({ description: \"Maximum tools to resolve from a query. Default 5.\" })),\n\t\t\t\t},\n\t\t\t\t{ additionalProperties: false },\n\t\t\t);\n\t\t\tpi.registerTool({\n\t\t\t\tname: RESOLVE_MCP_TOOLS_NAME,\n\t\t\t\tlabel: RESOLVE_MCP_TOOLS_NAME,\n\t\t\t\tdescription:\n\t\t\t\t\t\"MCP tools are connected but their schemas are loaded on demand to keep context small. Name the tool(s) \" +\n\t\t\t\t\t\"you need in `names`, or describe the capability in `query` to find them, then call the tool(s). \" +\n\t\t\t\t\t\"Available MCP tools:\\n\" +\n\t\t\t\t\tformatDeferredCatalog(deferredCatalog),\n\t\t\t\tpromptSnippet: \"Resolve deferred MCP tool schemas by name, or find them by describing the capability.\",\n\t\t\t\tparameters: resolveParams,\n\t\t\t\tasync execute(_toolCallId: string, params: Static<typeof resolveParams>) {\n\t\t\t\t\tconst names = params.names ?? [];\n\t\t\t\t\tconst query = params.query?.trim();\n\t\t\t\t\tconst matched = selectResolvable(deferredCatalog, names);\n\t\t\t\t\tconst seen = new Set(matched.map((m) => m.toolName));\n\n\t\t\t\t\t// Both parameters may be given: name what you know, describe the rest.\n\t\t\t\t\tlet searchNote = \"\";\n\t\t\t\t\tif (query) {\n\t\t\t\t\t\tconst { hits, legs } = await searchCapabilities(query, {\n\t\t\t\t\t\t\tkinds: [\"mcp-tool\"],\n\t\t\t\t\t\t\tlimit: params.limit ?? 5,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tfor (const hit of hits) {\n\t\t\t\t\t\t\tconst entry = deferredCatalog.find((e) => e.toolName === hit.doc.id);\n\t\t\t\t\t\t\tif (entry && !seen.has(entry.toolName)) {\n\t\t\t\t\t\t\t\tmatched.push(entry);\n\t\t\t\t\t\t\t\tseen.add(entry.toolName);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Say which legs answered: a lexical-only result set on a\n\t\t\t\t\t\t// conceptual query is a weaker answer, and the caller should be\n\t\t\t\t\t\t// able to tell that from the result rather than guess.\n\t\t\t\t\t\tsearchNote = hits.length\n\t\t\t\t\t\t\t? `\\nMatched by ${legs.join(\"+\") || \"none\"} search for \"${query}\".`\n\t\t\t\t\t\t\t: `\\nNothing matched \"${query}\".`;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst newlyResolved: string[] = [];\n\t\t\t\t\tfor (const entry of matched) {\n\t\t\t\t\t\tif (resolvedNames.has(entry.toolName)) continue;\n\t\t\t\t\t\tconst raw = deferredByName.get(entry.toolName);\n\t\t\t\t\t\tif (!raw) continue;\n\t\t\t\t\t\tpi.registerTool(buildMcpToolDefinition(raw.serverConfig, raw.tool));\n\t\t\t\t\t\tresolvedNames.add(entry.toolName);\n\t\t\t\t\t\tnewlyResolved.push(entry.toolName);\n\t\t\t\t\t}\n\t\t\t\t\tconst text = matched.length\n\t\t\t\t\t\t? `Resolved ${newlyResolved.length} MCP tool(s): ${matched.map((m) => m.toolName).join(\", \")}. They are now callable.${searchNote}`\n\t\t\t\t\t\t: `No MCP tools matched: ${[...names, query].filter(Boolean).join(\", \") || \"(none)\"}.${searchNote}`;\n\t\t\t\t\treturn { content: [{ type: \"text\" as const, text }], details: undefined };\n\t\t\t\t},\n\t\t\t} as ToolDefinition);\n\t\t}\n\t});\n}\n"]}
1
+ {"version":3,"file":"mcp-loader.d.ts","sourceRoot":"","sources":["../../../src/extensions/core/mcp-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAiBH,OAAO,KAAK,EAAE,YAAY,EAAuD,MAAM,gCAAgC,CAAC;AA8dxH,wBAAgB,cAAc,CAAC,EAAE,EAAE,YAAY,GAAG,IAAI,CAqRrD","sourcesContent":["/**\n * MCP server loader — discovers server configs (standard mcp.json locations,\n * hoocode's per-server JSON files, plugin registrations), connects via JSON-RPC\n * 2.0 over stdio (`command`), Streamable HTTP (`{ \"type\": \"http\", \"url\": ... }`),\n * or legacy SSE (`\"type\": \"sse\"`), and registers each server tool as\n * `mcp_<server>_<tool>`.\n *\n * Config sources (first-wins by server name):\n * 1. ~/.agents/mcp.json (user), ./.agents/mcp.json (project),\n * ~/.config/claude/mcp.json (Claude Desktop)\n * 2. ~/.hoocode/mcp-servers/*.json and ./.hoocode/mcp-servers/*.json\n * 3. MCP servers registered by plugins/extensions during load\n */\n\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { readdir } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { createInterface } from \"node:readline\";\nimport type { AgentToolResult, AgentToolUpdateCallback, McpRemoteOptions } from \"@kolisachint/hoocode-agent-core\";\nimport { connectHttpMcpServer, summarizeArgs } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { getHooCodeDir } from \"../../config.js\";\nimport { ensureDenseIndex } from \"../../core/capabilities/dense.js\";\nimport { registerCapabilities } from \"../../core/capabilities/registry.js\";\nimport { resetCapabilitySearch, searchCapabilities } from \"../../core/capabilities/search.js\";\nimport { getExtensionMcpServers } from \"../../core/extension-mcp-servers.js\";\nimport type { ExtensionAPI, ExtensionContext, SessionStartEvent, ToolDefinition } from \"../../core/extensions/types.js\";\nimport { formatDurationSecs } from \"../../core/format-duration.js\";\nimport { clearMcpServerStatuses, setMcpServerStatus } from \"../../core/mcp-status.js\";\nimport { deferMcpSchemas, subagentSkipMcp } from \"../../core/subagent-depth.js\";\nimport { taskStore } from \"../../core/task-store.js\";\nimport { shouldUseWindowsShell } from \"../../utils/child-process.js\";\nimport {\n\ttype DeferredMcpToolEntry,\n\tformatDeferredCatalog,\n\tselectResolvable,\n\ttoCapabilityDocs,\n} from \"./mcp-deferred.js\";\n\nconst HOOCODE_DIR = getHooCodeDir();\n\ninterface McpToolDetails {\n\telapsed?: number;\n}\n\ninterface McpToolDef {\n\tname: string;\n\tdescription: string;\n\tinputSchema?: {\n\t\ttype?: string;\n\t\tproperties?: Record<string, { type?: string; description?: string }>;\n\t\trequired?: string[];\n\t};\n}\n\ninterface McpServerConfig {\n\t/** Unique server identifier used as prefix for registered tool names */\n\tname: string;\n\t/** Executable to spawn (stdio transport). One of command/url is required. */\n\tcommand?: string;\n\t/** Optional arguments passed to the command */\n\targs?: string[];\n\t/** Optional extra environment variables for the server process */\n\tenv?: Record<string, string>;\n\t/** Transport: \"stdio\" (default with command), \"http\" (Streamable HTTP), or \"sse\" (legacy) */\n\ttype?: \"stdio\" | \"http\" | \"sse\";\n\t/** Remote server URL (http/sse transports) */\n\turl?: string;\n\t/** Extra HTTP headers (e.g. Authorization) for remote transports */\n\theaders?: Record<string, string>;\n\t/** Run MCP tools in background by default (default: true for MCP servers) */\n\tbackground?: boolean;\n}\n\n/** Standard MCP config format used by Claude Desktop, Claude Code, VS Code / Copilot */\ninterface StandardMcpServerConfig {\n\tcommand?: string;\n\targs?: string[];\n\tenv?: Record<string, string>;\n\ttype?: \"stdio\" | \"http\" | \"sse\";\n\turl?: string;\n\theaders?: Record<string, string>;\n\tbackground?: boolean;\n}\n\ninterface StandardMcpConfig {\n\tmcpServers?: Record<string, StandardMcpServerConfig>;\n}\n\ninterface McpConnection {\n\trpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;\n\t/** Send a JSON-RPC notification (no id, no response expected). */\n\tnotify(method: string, params?: unknown): void;\n\tterminate(): void;\n}\n\nconst mcpConnections = new Map<string, McpConnection>();\n/**\n * Server configs retained by name so a tool call can transparently reconnect a\n * dropped server (process churn between turns, server exit, a racing teardown)\n * instead of permanently failing with \"not connected\".\n */\nconst mcpServerConfigs = new Map<string, McpServerConfig>();\n/** Remote-transport options (OAuth storage, auth callbacks) per server name. */\nconst mcpRemoteOptions = new Map<string, McpRemoteOptions>();\n/**\n * Servers with a browser authorization flow in flight. While a name is here,\n * a failed handshake must NOT tear the connection down (that would kill the\n * OAuth loopback listener); the auth-completion handler reconnects instead.\n */\nconst mcpAuthPending = new Set<string>();\n\n/** Timeout for the connection handshake (initialize / tools/list). Tool calls\n * themselves are left untimed since MCP tools can be long-running. */\nconst MCP_HANDSHAKE_TIMEOUT_MS = 15000;\n\nfunction spawnMcpServer(config: McpServerConfig & { command: string }): McpConnection {\n\tconst proc: ChildProcess = spawn(config.command, config.args ?? [], {\n\t\tenv: { ...process.env, ...(config.env ?? {}) },\n\t\tstdio: [\"pipe\", \"pipe\", \"pipe\"],\n\t\t// `npx`/`npm` are .cmd shims on Windows, which spawn() cannot execute\n\t\t// directly — without this the launch fails with ENOENT.\n\t\tshell: shouldUseWindowsShell(config.command),\n\t});\n\n\tlet nextId = 1;\n\tconst pending = new Map<number, { resolve: (r: unknown) => void; reject: (e: Error) => void }>();\n\t/** Set once the child fails to launch, so later calls fail fast with the cause. */\n\tlet launchError: Error | undefined;\n\n\tconst rl = createInterface({ input: proc.stdout! });\n\trl.on(\"line\", (line) => {\n\t\tif (!line.trim()) return;\n\t\ttry {\n\t\t\tconst msg = JSON.parse(line) as {\n\t\t\t\tid?: number;\n\t\t\t\tresult?: unknown;\n\t\t\t\terror?: { message: string };\n\t\t\t};\n\t\t\tif (msg.id === undefined) return;\n\t\t\tconst cb = pending.get(msg.id);\n\t\t\tif (!cb) return;\n\t\t\tpending.delete(msg.id);\n\t\t\tif (msg.error) cb.reject(new Error(msg.error.message));\n\t\t\telse cb.resolve(msg.result);\n\t\t} catch {\n\t\t\t// ignore non-JSON server startup output\n\t\t}\n\t});\n\n\tproc.on(\"exit\", () => {\n\t\tfor (const cb of pending.values()) cb.reject(new Error(`MCP server \"${config.name}\" exited unexpectedly`));\n\t\tpending.clear();\n\t\tmcpConnections.delete(config.name);\n\t});\n\n\t// A ChildProcess that emits \"error\" with no listener takes the whole process\n\t// down with it. A missing MCP command is a bad config line, not a reason for\n\t// hoocode to refuse to start, so absorb it and let the handshake report it.\n\tproc.on(\"error\", (err: NodeJS.ErrnoException) => {\n\t\tlaunchError =\n\t\t\terr.code === \"ENOENT\"\n\t\t\t\t? new Error(`MCP server \"${config.name}\": command not found: ${config.command}`)\n\t\t\t\t: new Error(`MCP server \"${config.name}\" failed to start: ${err.message}`);\n\t\tfor (const cb of pending.values()) cb.reject(launchError);\n\t\tpending.clear();\n\t\tmcpConnections.delete(config.name);\n\t});\n\t// stdin dies with the child; its EPIPE surfaces through the same path.\n\tproc.stdin?.on(\"error\", () => {});\n\n\tfunction rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {\n\t\tif (launchError) return Promise.reject(launchError);\n\t\tconst id = nextId++;\n\t\treturn new Promise<unknown>((resolve, reject) => {\n\t\t\tlet timer: NodeJS.Timeout | undefined;\n\t\t\tif (timeoutMs && timeoutMs > 0) {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tif (pending.delete(id)) {\n\t\t\t\t\t\treject(new Error(`MCP server \"${config.name}\" timed out after ${timeoutMs}ms on ${method}`));\n\t\t\t\t\t}\n\t\t\t\t}, timeoutMs);\n\t\t\t\ttimer.unref?.();\n\t\t\t}\n\t\t\tpending.set(id, {\n\t\t\t\tresolve: (r) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tresolve(r);\n\t\t\t\t},\n\t\t\t\treject: (e) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\treject(e);\n\t\t\t\t},\n\t\t\t});\n\t\t\ttry {\n\t\t\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", id, method, params })}\\n`);\n\t\t\t} catch (err) {\n\t\t\t\tpending.delete(id);\n\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\treject(launchError ?? (err instanceof Error ? err : new Error(String(err))));\n\t\t\t}\n\t\t});\n\t}\n\n\tfunction notify(method: string, params?: unknown): void {\n\t\ttry {\n\t\t\tproc.stdin!.write(`${JSON.stringify({ jsonrpc: \"2.0\", method, params })}\\n`);\n\t\t} catch {\n\t\t\t// The server is gone; the pending rpc rejections already carry the reason.\n\t\t}\n\t}\n\n\treturn {\n\t\trpc,\n\t\tnotify,\n\t\tterminate: () => {\n\t\t\trl.close();\n\t\t\tproc.kill();\n\t\t},\n\t};\n}\n\n/** Remote servers use `{ type: \"http\" | \"sse\", url }`; stdio servers use `command`. */\nfunction isRemoteServer(config: McpServerConfig): boolean {\n\treturn config.type === \"http\" || config.type === \"sse\" || (!config.command && typeof config.url === \"string\");\n}\n\nfunction openMcpConnection(config: McpServerConfig): McpConnection {\n\tif (isRemoteServer(config)) {\n\t\tif (typeof config.url !== \"string\") {\n\t\t\tthrow new Error(`MCP server \"${config.name}\" has type \"${config.type}\" but no \"url\"`);\n\t\t}\n\t\treturn connectHttpMcpServer(\n\t\t\t{\n\t\t\t\tname: config.name,\n\t\t\t\turl: config.url,\n\t\t\t\theaders: config.headers,\n\t\t\t\ttype: config.type === \"sse\" ? \"sse\" : \"http\",\n\t\t\t},\n\t\t\tmcpRemoteOptions.get(config.name),\n\t\t);\n\t}\n\tif (typeof config.command !== \"string\") {\n\t\tthrow new Error(`MCP server \"${config.name}\" needs a \"command\" (stdio) or a \"url\" (http/sse)`);\n\t}\n\treturn spawnMcpServer(config as McpServerConfig & { command: string });\n}\n\nasync function connectMcpServer(config: McpServerConfig): Promise<{ conn: McpConnection; tools: McpToolDef[] }> {\n\tmcpConnections.get(config.name)?.terminate();\n\n\tconst conn = openMcpConnection(config);\n\tmcpConnections.set(config.name, conn);\n\n\ttry {\n\t\tawait conn.rpc(\n\t\t\t\"initialize\",\n\t\t\t{\n\t\t\t\tprotocolVersion: \"2024-11-05\",\n\t\t\t\tcapabilities: { tools: {} },\n\t\t\t\tclientInfo: { name: \"hoocode\", version: \"1.0.0\" },\n\t\t\t},\n\t\t\tMCP_HANDSHAKE_TIMEOUT_MS,\n\t\t);\n\n\t\t// Per the MCP spec the client must acknowledge a successful initialize with the\n\t\t// initialized notification before issuing further requests; strict servers gate\n\t\t// tools/call on it.\n\t\tconn.notify(\"notifications/initialized\");\n\n\t\tconst toolsResult = (await conn.rpc(\"tools/list\", {}, MCP_HANDSHAKE_TIMEOUT_MS)) as {\n\t\t\ttools?: McpToolDef[];\n\t\t};\n\t\treturn { conn, tools: toolsResult.tools ?? [] };\n\t} catch (error) {\n\t\t// Don't leave a half-connected entry behind: remote transports have no\n\t\t// process-exit event to evict them, so getOrConnectMcp would keep handing\n\t\t// out the dead connection instead of reconnecting. Exception: a pending\n\t\t// browser authorization — terminating would kill the OAuth loopback\n\t\t// listener; the auth-completion handler reconnects that server itself.\n\t\tif (!mcpAuthPending.has(config.name)) {\n\t\t\tconn.terminate();\n\t\t\tif (mcpConnections.get(config.name) === conn) mcpConnections.delete(config.name);\n\t\t}\n\t\tthrow error;\n\t}\n}\n\n/**\n * Return a live connection for a server, lazily reconnecting from the retained\n * config when the previous connection was torn down. Returns undefined only when\n * no config is known or a fresh connect attempt fails.\n */\nasync function getOrConnectMcp(name: string): Promise<McpConnection | undefined> {\n\tconst existing = mcpConnections.get(name);\n\tif (existing) return existing;\n\tconst config = mcpServerConfigs.get(name);\n\tif (!config) return undefined;\n\ttry {\n\t\tconst { conn } = await connectMcpServer(config);\n\t\treturn conn;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nlet mcpExitCleanupInstalled = false;\n/** Kill spawned MCP servers when the host process exits so they don't linger as\n * orphans (their stdin merely goes idle, which doesn't terminate them). */\nfunction installMcpExitCleanup(): void {\n\tif (mcpExitCleanupInstalled) return;\n\tmcpExitCleanupInstalled = true;\n\tprocess.once(\"exit\", () => {\n\t\tfor (const conn of mcpConnections.values()) {\n\t\t\ttry {\n\t\t\t\tconn.terminate();\n\t\t\t} catch {\n\t\t\t\t// best-effort cleanup\n\t\t\t}\n\t\t}\n\t\tmcpConnections.clear();\n\t});\n}\n\nfunction buildMcpSchema(tool: McpToolDef): ReturnType<typeof Type.Object> {\n\tconst props = tool.inputSchema?.properties ?? {};\n\tconst required = new Set(tool.inputSchema?.required ?? []);\n\tconst shape: Record<string, ReturnType<typeof Type.String>> = {};\n\n\tfor (const [key, prop] of Object.entries(props)) {\n\t\tlet field: ReturnType<typeof Type.String>;\n\t\tswitch (prop.type) {\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\t\tfield = Type.Number({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tcase \"boolean\":\n\t\t\t\tfield = Type.Boolean({ description: prop.description }) as unknown as ReturnType<typeof Type.String>;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tfield = Type.String({ description: prop.description });\n\t\t}\n\t\tshape[key] = required.has(key) ? field : (Type.Optional(field) as unknown as ReturnType<typeof Type.String>);\n\t}\n\n\treturn Type.Object(shape);\n}\n\n/**\n * Parse standard MCP config format (used by Claude Desktop, VS Code, etc.)\n * into hoocode's McpServerConfig format.\n */\nfunction parseStandardMcpConfig(config: StandardMcpConfig, _source: string): McpServerConfig[] {\n\tif (!config.mcpServers) return [];\n\n\tconst servers: McpServerConfig[] = [];\n\tfor (const [name, serverConfig] of Object.entries(config.mcpServers)) {\n\t\tservers.push({\n\t\t\tname,\n\t\t\tcommand: serverConfig.command,\n\t\t\targs: serverConfig.args,\n\t\t\tenv: serverConfig.env,\n\t\t\ttype: serverConfig.type,\n\t\t\turl: serverConfig.url,\n\t\t\theaders: serverConfig.headers,\n\t\t\tbackground: serverConfig.background,\n\t\t});\n\t}\n\treturn servers;\n}\n\n/**\n * Load MCP servers from a standard mcp.json file.\n * Returns an array of McpServerConfig, or empty array if file doesn't exist or is invalid.\n */\nfunction loadStandardMcpFile(filePath: string): McpServerConfig[] {\n\tif (!existsSync(filePath)) return [];\n\n\ttry {\n\t\tconst content = readFileSync(filePath, \"utf8\");\n\t\tconst config = JSON.parse(content) as StandardMcpConfig;\n\t\treturn parseStandardMcpConfig(config, filePath);\n\t} catch {\n\t\treturn [];\n\t}\n}\n\n/**\n * Build the full {@link ToolDefinition} for one MCP tool — the complete JSON\n * schema plus the connect/execute machinery. Shared by the eager path (register\n * every tool up front) and the deferred path (materialize on resolve), so both\n * produce identical, callable tools.\n */\nfunction buildMcpToolDefinition(serverConfig: McpServerConfig, tool: McpToolDef): ToolDefinition {\n\tconst toolName = `mcp_${serverConfig.name}_${tool.name}`;\n\tconst schema = buildMcpSchema(tool);\n\tconst capturedServer = serverConfig.name;\n\tconst capturedTool = tool.name;\n\t// MCP tools default to background mode since they are external processes with potential high latency\n\tconst isBackground = serverConfig.background !== false;\n\n\treturn {\n\t\tname: toolName,\n\t\tlabel: `[MCP] ${serverConfig.name} › ${tool.name}`,\n\t\tdescription: tool.description,\n\t\tparameters: schema,\n\t\tbackground: isBackground,\n\t\t// Render a clean, prefixed title in chat — `MCP [server › tool] <args>` —\n\t\t// parallel to the subagent `Task [type] <desc>` line. Without this the\n\t\t// ToolExecutionComponent falls back to the raw `mcp_<server>_<tool>` name.\n\t\t// The args summary reuses the same helper as the background start/finish\n\t\t// messages so the chat title stays in sync with them.\n\t\trenderCall(args, theme) {\n\t\t\tconst summary = summarizeArgs((args ?? {}) as Record<string, unknown>);\n\t\t\tconst text =\n\t\t\t\ttheme.fg(\"toolTitle\", theme.bold(\"MCP \")) +\n\t\t\t\ttheme.fg(\"mcp\", `[${capturedServer} › ${capturedTool}]`) +\n\t\t\t\t(summary ? theme.fg(\"dim\", ` ${summary}`) : \"\");\n\t\t\treturn new Text(text, 0, 0);\n\t\t},\n\t\tasync execute(\n\t\t\t_toolCallId: string,\n\t\t\tparams: Static<typeof schema>,\n\t\t\tsignal: AbortSignal,\n\t\t\t_onUpdate: AgentToolUpdateCallback,\n\t\t): Promise<AgentToolResult<McpToolDetails>> {\n\t\t\tconst startTime = Date.now();\n\t\t\t// Background MCP tools get a task store entry so they appear in the task pane.\n\t\t\t// Foreground tools skip this (their result is awaited inline). The server\n\t\t\t// name rides in subagentMode and becomes the row's `[server]` origin tag;\n\t\t\t// the title carries just the tool.\n\t\t\tconst task = isBackground\n\t\t\t\t? taskStore.create(capturedTool, { source: \"mcp\", subagentMode: capturedServer })\n\t\t\t\t: undefined;\n\t\t\tif (task) taskStore.update(task.id, { status: \"in_progress\" });\n\n\t\t\t// Lazily (re)connect: a dropped connection (server exit, process churn\n\t\t\t// between turns, a racing teardown) should transparently reconnect from\n\t\t\t// the retained config rather than permanently fail with \"not connected\".\n\t\t\tconst activeConn = await getOrConnectMcp(capturedServer);\n\t\t\tif (!activeConn) {\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"failed\" });\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [\n\t\t\t\t\t\t{ type: \"text\", text: `MCP server \"${capturedServer}\" is not connected (reconnect attempt failed)` },\n\t\t\t\t\t],\n\t\t\t\t\tdetails: { elapsed: Date.now() - startTime } as McpToolDetails,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst abortPromise = new Promise<never>((_, reject) => {\n\t\t\t\t\tsignal.addEventListener(\"abort\", () => reject(new Error(\"Aborted\")));\n\t\t\t\t});\n\n\t\t\t\tconst result = await Promise.race([\n\t\t\t\t\tactiveConn.rpc(\"tools/call\", { name: capturedTool, arguments: params }),\n\t\t\t\t\tabortPromise,\n\t\t\t\t]);\n\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"done\" });\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: JSON.stringify(result, null, 2) }],\n\t\t\t\t\tdetails: { elapsed: Date.now() - startTime } as McpToolDetails,\n\t\t\t\t};\n\t\t\t} catch (error) {\n\t\t\t\tif (task) taskStore.update(task.id, { status: \"failed\" });\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t\trenderResult(result, _options, theme, context) {\n\t\t\tconst text = result.content\n\t\t\t\t.map((c) => (c.type === \"text\" ? c.text : \"\"))\n\t\t\t\t.filter(Boolean)\n\t\t\t\t.join(\"\\n\");\n\t\t\tif (!text) return new Text(\"\", 0, 0);\n\n\t\t\tconst details = result.details as McpToolDetails | undefined;\n\t\t\tconst elapsed = details?.elapsed;\n\t\t\tconst elapsedText = elapsed ? ` ${formatDurationSecs(elapsed / 1000)}` : \"\";\n\t\t\tconst identity = `${capturedServer} › ${capturedTool}`;\n\n\t\t\t// Use context.isError to determine status (framework sets this when tools throw)\n\t\t\tconst isError = context.isError;\n\t\t\tconst statusGlyph = isError ? \"✗\" : \"✓\";\n\t\t\tconst statusLabel = isError ? \"failed\" : \"done\";\n\t\t\tconst statusColor = isError ? \"error\" : \"success\";\n\n\t\t\tconst spine = (s: string) => theme.fg(\"borderMuted\", s);\n\t\t\tconst header =\n\t\t\t\t`${spine(\"╭\")} ${theme.fg(statusColor, statusGlyph)} ` +\n\t\t\t\t`${theme.bold(theme.fg(statusColor, statusLabel))} ` +\n\t\t\t\t`${theme.fg(\"mcp\", identity)}${elapsedText}`;\n\t\t\tconst body = text\n\t\t\t\t.split(\"\\n\")\n\t\t\t\t.map((line) => `${spine(\"│\")} ${theme.fg(\"toolOutput\", line)}`)\n\t\t\t\t.join(\"\\n\");\n\t\t\treturn new Text(`${header}\\n${body}\\n${spine(\"╰\")}`, 0, 0);\n\t\t},\n\t} as ToolDefinition;\n}\n\nconst RESOLVE_MCP_TOOLS_NAME = \"ResolveMcpTools\";\n\nexport function setupMcpLoader(pi: ExtensionAPI): void {\n\tpi.on(\"session_start\", async (_event: SessionStartEvent, ctx: ExtensionContext) => {\n\t\t// A spawned subagent whose tool allowlist has no MCP tools is told by its\n\t\t// parent to skip server connection entirely (see SUBAGENT_SKIP_MCP_ENV).\n\t\t// Each connect is a ~15s-timeout handshake; doing it for a subagent that can\n\t\t// never call the tools is pure startup latency.\n\t\tif (subagentSkipMcp()) return;\n\n\t\tinstallMcpExitCleanup();\n\t\t// Rebuild the status registry from scratch: a reload or session switch runs\n\t\t// this pass again and the startup summary must not double-count.\n\t\tclearMcpServerStatuses();\n\t\tconst allServerConfigs: McpServerConfig[] = [];\n\t\tconst seenNames = new Set<string>();\n\n\t\t// 1. Load from standard mcp.json locations\n\t\t// User-level: ~/.agents/mcp.json\n\t\tconst userAgentsConfig = loadStandardMcpFile(join(homedir(), \".agents\", \"mcp.json\"));\n\t\tfor (const config of userAgentsConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// Project-level: ./.agents/mcp.json\n\t\tconst projectAgentsConfig = loadStandardMcpFile(join(ctx.cwd, \".agents\", \"mcp.json\"));\n\t\tfor (const config of projectAgentsConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// Claude Desktop: ~/.config/claude/mcp.json\n\t\tconst claudeDesktopConfig = loadStandardMcpFile(join(homedir(), \".config\", \"claude\", \"mcp.json\"));\n\t\tfor (const config of claudeDesktopConfig) {\n\t\t\tif (!seenNames.has(config.name)) {\n\t\t\t\tseenNames.add(config.name);\n\t\t\t\tallServerConfigs.push(config);\n\t\t\t}\n\t\t}\n\n\t\t// 2. Load from hoocode's per-server format (existing behavior)\n\t\tconst searchDirs = [join(HOOCODE_DIR, \"mcp-servers\"), join(ctx.cwd, \".hoocode\", \"mcp-servers\")];\n\n\t\tfor (const dir of searchDirs) {\n\t\t\tif (!existsSync(dir)) continue;\n\n\t\t\tlet files: string[];\n\t\t\ttry {\n\t\t\t\tfiles = (await readdir(dir)).filter((f) => f.endsWith(\".json\"));\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tfor (const file of files) {\n\t\t\t\tconst cfgPath = join(dir, file);\n\t\t\t\tlet serverConfig: McpServerConfig;\n\n\t\t\t\ttry {\n\t\t\t\t\tserverConfig = JSON.parse(readFileSync(cfgPath, \"utf8\")) as McpServerConfig;\n\t\t\t\t\tif (!serverConfig.name || (!serverConfig.command && !serverConfig.url)) {\n\t\t\t\t\t\tctx.ui.notify(`MCP: config \"${file}\" is missing required \"name\" or \"command\"/\"url\"`, \"warning\");\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t} catch (err) {\n\t\t\t\t\tctx.ui.notify(`MCP: failed to parse \"${file}\": ${String(err)}`, \"error\");\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// Skip if already loaded from standard config\n\t\t\t\tif (seenNames.has(serverConfig.name)) continue;\n\t\t\t\tseenNames.add(serverConfig.name);\n\t\t\t\tallServerConfigs.push(serverConfig);\n\t\t\t}\n\t\t}\n\n\t\t// 2b. Load from plugins/extensions that registered MCP servers during load.\n\t\tfor (const entry of getExtensionMcpServers()) {\n\t\t\tfor (const serverConfig of parseStandardMcpConfig(\n\t\t\t\t{ mcpServers: entry.mcpServers },\n\t\t\t\t`plugin:${entry.source}`,\n\t\t\t)) {\n\t\t\t\tif (seenNames.has(serverConfig.name)) continue;\n\t\t\t\tseenNames.add(serverConfig.name);\n\t\t\t\tallServerConfigs.push(serverConfig);\n\t\t\t}\n\t\t}\n\n\t\t// Deferral (spec §2): inject MCP tool names only and materialize each schema\n\t\t// on demand via ResolveMcpTools. Default-on and top-level only — a subagent that\n\t\t// needs MCP has this env cleared, so it eager-registers its allowlisted tools\n\t\t// at dispatch (the dispatch ↔ schema interaction) and they are immediately callable.\n\t\tconst defer = deferMcpSchemas();\n\t\tconst deferredCatalog: DeferredMcpToolEntry[] = [];\n\t\t// Retain each deferred tool's raw definition + config so ResolveMcpTools can\n\t\t// build the full ToolDefinition on request.\n\t\tconst deferredByName = new Map<string, { serverConfig: McpServerConfig; tool: McpToolDef }>();\n\t\tconst resolvedNames = new Set<string>();\n\n\t\t// Remote servers get OAuth support: tokens persist under\n\t\t// ~/.hoocode/mcp-auth, and when a server demands interactive (browser)\n\t\t// authorization the handshake fails fast while the flow keeps running in\n\t\t// the background — on completion the server is reconnected and its tools\n\t\t// registered (eagerly, since the deferred catalog is already sealed).\n\t\tconst buildRemoteOptions = (serverConfig: McpServerConfig): McpRemoteOptions => ({\n\t\t\tauthStorageDir: join(HOOCODE_DIR, \"mcp-auth\"),\n\t\t\tonAuthRequired: (authorizationUrl, completed) => {\n\t\t\t\tmcpAuthPending.add(serverConfig.name);\n\t\t\t\tctx.ui.notify(\n\t\t\t\t\t`MCP: \"${serverConfig.name}\" requires authorization — complete the sign-in opened in your browser` +\n\t\t\t\t\t\t(authorizationUrl ? `: ${authorizationUrl}` : \"\"),\n\t\t\t\t\t\"warning\",\n\t\t\t\t);\n\t\t\t\tcompleted\n\t\t\t\t\t.then(async () => {\n\t\t\t\t\t\tmcpAuthPending.delete(serverConfig.name);\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst { tools } = await connectMcpServer(serverConfig);\n\t\t\t\t\t\t\tfor (const tool of tools) pi.registerTool(buildMcpToolDefinition(serverConfig, tool));\n\t\t\t\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\t\t\t\ttoolCount: tools.length,\n\t\t\t\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\t\t\t\tdeferred: false,\n\t\t\t\t\t\t\t\tstate: \"connected\",\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t\t\t`MCP: connected \"${serverConfig.name}\" after authorization (${tools.length} tool${tools.length === 1 ? \"\" : \"s\"})`,\n\t\t\t\t\t\t\t\t\"info\",\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t\t\t`MCP: failed to connect \"${serverConfig.name}\" after authorization: ${String(err)}`,\n\t\t\t\t\t\t\t\t\"error\",\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\t\tmcpAuthPending.delete(serverConfig.name);\n\t\t\t\t\t\tctx.ui.notify(`MCP: authorization for \"${serverConfig.name}\" failed: ${String(err)}`, \"error\");\n\t\t\t\t\t});\n\t\t\t},\n\t\t});\n\n\t\t// 3. Connect to all servers and register (or defer) tools\n\t\tfor (const serverConfig of allServerConfigs) {\n\t\t\t// Retain the config so a tool call can lazily reconnect a dropped server.\n\t\t\tmcpServerConfigs.set(serverConfig.name, serverConfig);\n\t\t\tmcpRemoteOptions.set(serverConfig.name, buildRemoteOptions(serverConfig));\n\t\t\ttry {\n\t\t\t\tconst { tools } = await connectMcpServer(serverConfig);\n\n\t\t\t\tfor (const tool of tools) {\n\t\t\t\t\tconst toolName = `mcp_${serverConfig.name}_${tool.name}`;\n\t\t\t\t\tif (defer) {\n\t\t\t\t\t\tdeferredCatalog.push({ toolName, server: serverConfig.name, description: tool.description });\n\t\t\t\t\t\tdeferredByName.set(toolName, { serverConfig, tool });\n\t\t\t\t\t} else {\n\t\t\t\t\t\tpi.registerTool(buildMcpToolDefinition(serverConfig, tool));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// A successful connect is reported by the startup resource summary (one\n\t\t\t\t// `mcp` cell plus a details row), not as a transient line above it.\n\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\ttoolCount: tools.length,\n\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\tdeferred: defer,\n\t\t\t\t\tstate: \"connected\",\n\t\t\t\t});\n\t\t\t} catch (err) {\n\t\t\t\tif (mcpAuthPending.has(serverConfig.name)) {\n\t\t\t\t\tsetMcpServerStatus({\n\t\t\t\t\t\tname: serverConfig.name,\n\t\t\t\t\t\ttoolCount: 0,\n\t\t\t\t\t\tbackground: serverConfig.background !== false,\n\t\t\t\t\t\tdeferred: defer,\n\t\t\t\t\t\tstate: \"authorizing\",\n\t\t\t\t\t});\n\t\t\t\t\tctx.ui.notify(\n\t\t\t\t\t\t`MCP: \"${serverConfig.name}\" is waiting for browser authorization; tools will register once it completes`,\n\t\t\t\t\t\t\"info\",\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tctx.ui.notify(`MCP: failed to connect \"${serverConfig.name}\": ${String(err)}`, \"error\");\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 4. In deferred mode, register the single resolver that materializes schemas on demand.\n\t\tif (defer && deferredCatalog.length > 0) {\n\t\t\t// Feed the capability index. This is what lets the catalog above be\n\t\t\t// summarized instead of dumped: a tool the model cannot see in the\n\t\t\t// description is still reachable by describing what it needs.\n\t\t\tregisterCapabilities(\"mcp-tool\", toCapabilityDocs(deferredCatalog, true));\n\t\t\tresetCapabilitySearch();\n\t\t\t// Fire-and-forget: the lexical leg answers immediately, and the dense one\n\t\t\t// joins when (and if) it is ready. Awaiting here would put an embedding\n\t\t\t// model's startup in front of the session's.\n\t\t\tvoid ensureDenseIndex(toCapabilityDocs(deferredCatalog, true)).catch(() => {});\n\n\t\t\tconst resolveParams = Type.Object(\n\t\t\t\t{\n\t\t\t\t\tnames: Type.Optional(\n\t\t\t\t\t\tType.Array(Type.String(), {\n\t\t\t\t\t\t\tdescription: \"Exact MCP tool names to make callable (e.g. 'mcp_github_create_pr' or 'create_pr').\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t\tquery: Type.Optional(\n\t\t\t\t\t\tType.String({\n\t\t\t\t\t\t\tdescription:\n\t\t\t\t\t\t\t\t\"Describe the capability you need ('open a pull request', 'send a message') to find tools by \" +\n\t\t\t\t\t\t\t\t\"meaning when you do not know their names. Matching tools are resolved and become callable.\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t\tlimit: Type.Optional(Type.Number({ description: \"Maximum tools to resolve from a query. Default 5.\" })),\n\t\t\t\t},\n\t\t\t\t{ additionalProperties: false },\n\t\t\t);\n\t\t\tpi.registerTool({\n\t\t\t\tname: RESOLVE_MCP_TOOLS_NAME,\n\t\t\t\tlabel: RESOLVE_MCP_TOOLS_NAME,\n\t\t\t\tdescription:\n\t\t\t\t\t\"MCP tools are connected but their schemas are loaded on demand to keep context small. Name the tool(s) \" +\n\t\t\t\t\t\"you need in `names`, or describe the capability in `query` to find them, then call the tool(s). \" +\n\t\t\t\t\t\"Available MCP tools:\\n\" +\n\t\t\t\t\tformatDeferredCatalog(deferredCatalog),\n\t\t\t\tpromptSnippet: \"Resolve deferred MCP tool schemas by name, or find them by describing the capability.\",\n\t\t\t\tparameters: resolveParams,\n\t\t\t\tasync execute(_toolCallId: string, params: Static<typeof resolveParams>) {\n\t\t\t\t\tconst names = params.names ?? [];\n\t\t\t\t\tconst query = params.query?.trim();\n\t\t\t\t\tconst matched = selectResolvable(deferredCatalog, names);\n\t\t\t\t\tconst seen = new Set(matched.map((m) => m.toolName));\n\n\t\t\t\t\t// Both parameters may be given: name what you know, describe the rest.\n\t\t\t\t\tlet searchNote = \"\";\n\t\t\t\t\tif (query) {\n\t\t\t\t\t\tconst { hits, legs } = await searchCapabilities(query, {\n\t\t\t\t\t\t\tkinds: [\"mcp-tool\"],\n\t\t\t\t\t\t\tlimit: params.limit ?? 5,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tfor (const hit of hits) {\n\t\t\t\t\t\t\tconst entry = deferredCatalog.find((e) => e.toolName === hit.doc.id);\n\t\t\t\t\t\t\tif (entry && !seen.has(entry.toolName)) {\n\t\t\t\t\t\t\t\tmatched.push(entry);\n\t\t\t\t\t\t\t\tseen.add(entry.toolName);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Say which legs answered: a lexical-only result set on a\n\t\t\t\t\t\t// conceptual query is a weaker answer, and the caller should be\n\t\t\t\t\t\t// able to tell that from the result rather than guess.\n\t\t\t\t\t\tsearchNote = hits.length\n\t\t\t\t\t\t\t? `\\nMatched by ${legs.join(\"+\") || \"none\"} search for \"${query}\".`\n\t\t\t\t\t\t\t: `\\nNothing matched \"${query}\".`;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst newlyResolved: string[] = [];\n\t\t\t\t\tfor (const entry of matched) {\n\t\t\t\t\t\tif (resolvedNames.has(entry.toolName)) continue;\n\t\t\t\t\t\tconst raw = deferredByName.get(entry.toolName);\n\t\t\t\t\t\tif (!raw) continue;\n\t\t\t\t\t\tpi.registerTool(buildMcpToolDefinition(raw.serverConfig, raw.tool));\n\t\t\t\t\t\tresolvedNames.add(entry.toolName);\n\t\t\t\t\t\tnewlyResolved.push(entry.toolName);\n\t\t\t\t\t}\n\t\t\t\t\tconst text = matched.length\n\t\t\t\t\t\t? `Resolved ${newlyResolved.length} MCP tool(s): ${matched.map((m) => m.toolName).join(\", \")}. They are now callable.${searchNote}`\n\t\t\t\t\t\t: `No MCP tools matched: ${[...names, query].filter(Boolean).join(\", \") || \"(none)\"}.${searchNote}`;\n\t\t\t\t\treturn { content: [{ type: \"text\" as const, text }], details: undefined };\n\t\t\t\t},\n\t\t\t} as ToolDefinition);\n\t\t}\n\t});\n}\n"]}
@@ -29,6 +29,7 @@ import { formatDurationSecs } from "../../core/format-duration.js";
29
29
  import { clearMcpServerStatuses, setMcpServerStatus } from "../../core/mcp-status.js";
30
30
  import { deferMcpSchemas, subagentSkipMcp } from "../../core/subagent-depth.js";
31
31
  import { taskStore } from "../../core/task-store.js";
32
+ import { shouldUseWindowsShell } from "../../utils/child-process.js";
32
33
  import { formatDeferredCatalog, selectResolvable, toCapabilityDocs, } from "./mcp-deferred.js";
33
34
  const HOOCODE_DIR = getHooCodeDir();
34
35
  const mcpConnections = new Map();
@@ -53,9 +54,14 @@ function spawnMcpServer(config) {
53
54
  const proc = spawn(config.command, config.args ?? [], {
54
55
  env: { ...process.env, ...(config.env ?? {}) },
55
56
  stdio: ["pipe", "pipe", "pipe"],
57
+ // `npx`/`npm` are .cmd shims on Windows, which spawn() cannot execute
58
+ // directly — without this the launch fails with ENOENT.
59
+ shell: shouldUseWindowsShell(config.command),
56
60
  });
57
61
  let nextId = 1;
58
62
  const pending = new Map();
63
+ /** Set once the child fails to launch, so later calls fail fast with the cause. */
64
+ let launchError;
59
65
  const rl = createInterface({ input: proc.stdout });
60
66
  rl.on("line", (line) => {
61
67
  if (!line.trim())
@@ -83,7 +89,24 @@ function spawnMcpServer(config) {
83
89
  pending.clear();
84
90
  mcpConnections.delete(config.name);
85
91
  });
92
+ // A ChildProcess that emits "error" with no listener takes the whole process
93
+ // down with it. A missing MCP command is a bad config line, not a reason for
94
+ // hoocode to refuse to start, so absorb it and let the handshake report it.
95
+ proc.on("error", (err) => {
96
+ launchError =
97
+ err.code === "ENOENT"
98
+ ? new Error(`MCP server "${config.name}": command not found: ${config.command}`)
99
+ : new Error(`MCP server "${config.name}" failed to start: ${err.message}`);
100
+ for (const cb of pending.values())
101
+ cb.reject(launchError);
102
+ pending.clear();
103
+ mcpConnections.delete(config.name);
104
+ });
105
+ // stdin dies with the child; its EPIPE surfaces through the same path.
106
+ proc.stdin?.on("error", () => { });
86
107
  function rpc(method, params, timeoutMs) {
108
+ if (launchError)
109
+ return Promise.reject(launchError);
87
110
  const id = nextId++;
88
111
  return new Promise((resolve, reject) => {
89
112
  let timer;
@@ -107,11 +130,24 @@ function spawnMcpServer(config) {
107
130
  reject(e);
108
131
  },
109
132
  });
110
- proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
133
+ try {
134
+ proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
135
+ }
136
+ catch (err) {
137
+ pending.delete(id);
138
+ if (timer)
139
+ clearTimeout(timer);
140
+ reject(launchError ?? (err instanceof Error ? err : new Error(String(err))));
141
+ }
111
142
  });
112
143
  }
113
144
  function notify(method, params) {
114
- proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
145
+ try {
146
+ proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
147
+ }
148
+ catch {
149
+ // The server is gone; the pending rpc rejections already carry the reason.
150
+ }
115
151
  }
116
152
  return {
117
153
  rpc,