@kolisachint/hoocode-agent 0.5.2 → 0.5.4

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 (174) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli/args.d.ts +1 -1
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +10 -7
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/core/agent-session.d.ts.map +1 -1
  7. package/dist/core/agent-session.js +10 -2
  8. package/dist/core/agent-session.js.map +1 -1
  9. package/dist/core/capabilities/deferral.d.ts +78 -0
  10. package/dist/core/capabilities/deferral.d.ts.map +1 -0
  11. package/dist/core/capabilities/deferral.js +99 -0
  12. package/dist/core/capabilities/deferral.js.map +1 -0
  13. package/dist/core/capabilities/dense.d.ts +51 -0
  14. package/dist/core/capabilities/dense.d.ts.map +1 -0
  15. package/dist/core/capabilities/dense.js +121 -0
  16. package/dist/core/capabilities/dense.js.map +1 -0
  17. package/dist/core/capabilities/lexical.d.ts +43 -0
  18. package/dist/core/capabilities/lexical.d.ts.map +1 -0
  19. package/dist/core/capabilities/lexical.js +104 -0
  20. package/dist/core/capabilities/lexical.js.map +1 -0
  21. package/dist/core/capabilities/registry.d.ts +56 -0
  22. package/dist/core/capabilities/registry.d.ts.map +1 -0
  23. package/dist/core/capabilities/registry.js +0 -0
  24. package/dist/core/capabilities/registry.js.map +1 -0
  25. package/dist/core/capabilities/search.d.ts +47 -0
  26. package/dist/core/capabilities/search.d.ts.map +1 -0
  27. package/dist/core/capabilities/search.js +82 -0
  28. package/dist/core/capabilities/search.js.map +1 -0
  29. package/dist/core/extensions/loader.d.ts +27 -3
  30. package/dist/core/extensions/loader.d.ts.map +1 -1
  31. package/dist/core/extensions/loader.js +61 -7
  32. package/dist/core/extensions/loader.js.map +1 -1
  33. package/dist/core/extensions/plugins/authoring.d.ts +56 -9
  34. package/dist/core/extensions/plugins/authoring.d.ts.map +1 -1
  35. package/dist/core/extensions/plugins/authoring.js +0 -0
  36. package/dist/core/extensions/plugins/authoring.js.map +1 -1
  37. package/dist/core/extensions/plugins/drift.d.ts +75 -0
  38. package/dist/core/extensions/plugins/drift.d.ts.map +1 -0
  39. package/dist/core/extensions/plugins/drift.js +194 -0
  40. package/dist/core/extensions/plugins/drift.js.map +1 -0
  41. package/dist/core/extensions/plugins/formats/claude.d.ts.map +1 -1
  42. package/dist/core/extensions/plugins/formats/claude.js +5 -0
  43. package/dist/core/extensions/plugins/formats/claude.js.map +1 -1
  44. package/dist/core/extensions/plugins/formats/copilot.d.ts +6 -0
  45. package/dist/core/extensions/plugins/formats/copilot.d.ts.map +1 -1
  46. package/dist/core/extensions/plugins/formats/copilot.js +57 -16
  47. package/dist/core/extensions/plugins/formats/copilot.js.map +1 -1
  48. package/dist/core/extensions/plugins/formats/index.d.ts +39 -1
  49. package/dist/core/extensions/plugins/formats/index.d.ts.map +1 -1
  50. package/dist/core/extensions/plugins/formats/index.js +90 -13
  51. package/dist/core/extensions/plugins/formats/index.js.map +1 -1
  52. package/dist/core/extensions/plugins/formats/jsonManifest.d.ts +14 -0
  53. package/dist/core/extensions/plugins/formats/jsonManifest.d.ts.map +1 -1
  54. package/dist/core/extensions/plugins/formats/jsonManifest.js +37 -4
  55. package/dist/core/extensions/plugins/formats/jsonManifest.js.map +1 -1
  56. package/dist/core/extensions/plugins/formats/platform-targets.d.ts +48 -24
  57. package/dist/core/extensions/plugins/formats/platform-targets.d.ts.map +1 -1
  58. package/dist/core/extensions/plugins/formats/platform-targets.js +62 -30
  59. package/dist/core/extensions/plugins/formats/platform-targets.js.map +1 -1
  60. package/dist/core/extensions/plugins/formats/shared.d.ts +22 -0
  61. package/dist/core/extensions/plugins/formats/shared.d.ts.map +1 -1
  62. package/dist/core/extensions/plugins/formats/shared.js +81 -4
  63. package/dist/core/extensions/plugins/formats/shared.js.map +1 -1
  64. package/dist/core/extensions/plugins/formats/types.d.ts +10 -1
  65. package/dist/core/extensions/plugins/formats/types.d.ts.map +1 -1
  66. package/dist/core/extensions/plugins/formats/types.js.map +1 -1
  67. package/dist/core/extensions/plugins/gates.d.ts +64 -0
  68. package/dist/core/extensions/plugins/gates.d.ts.map +1 -0
  69. package/dist/core/extensions/plugins/gates.js +297 -0
  70. package/dist/core/extensions/plugins/gates.js.map +1 -0
  71. package/dist/core/extensions/plugins/hooks-bridge.d.ts +1 -1
  72. package/dist/core/extensions/plugins/hooks-bridge.d.ts.map +1 -1
  73. package/dist/core/extensions/plugins/hooks-bridge.js +12 -8
  74. package/dist/core/extensions/plugins/hooks-bridge.js.map +1 -1
  75. package/dist/core/extensions/plugins/index.d.ts +36 -5
  76. package/dist/core/extensions/plugins/index.d.ts.map +1 -1
  77. package/dist/core/extensions/plugins/index.js +66 -15
  78. package/dist/core/extensions/plugins/index.js.map +1 -1
  79. package/dist/core/extensions/plugins/install.d.ts +43 -24
  80. package/dist/core/extensions/plugins/install.d.ts.map +1 -1
  81. package/dist/core/extensions/plugins/install.js +167 -54
  82. package/dist/core/extensions/plugins/install.js.map +1 -1
  83. package/dist/core/extensions/plugins/locations.d.ts +84 -0
  84. package/dist/core/extensions/plugins/locations.d.ts.map +1 -0
  85. package/dist/core/extensions/plugins/locations.js +137 -0
  86. package/dist/core/extensions/plugins/locations.js.map +1 -0
  87. package/dist/core/extensions/plugins/manifest.d.ts +21 -1
  88. package/dist/core/extensions/plugins/manifest.d.ts.map +1 -1
  89. package/dist/core/extensions/plugins/manifest.js +2 -2
  90. package/dist/core/extensions/plugins/manifest.js.map +1 -1
  91. package/dist/core/extensions/plugins/packaging.d.ts +115 -0
  92. package/dist/core/extensions/plugins/packaging.d.ts.map +1 -0
  93. package/dist/core/extensions/plugins/packaging.js +362 -0
  94. package/dist/core/extensions/plugins/packaging.js.map +1 -0
  95. package/dist/core/extensions/plugins/smoke.d.ts +33 -0
  96. package/dist/core/extensions/plugins/smoke.d.ts.map +1 -0
  97. package/dist/core/extensions/plugins/smoke.js +231 -0
  98. package/dist/core/extensions/plugins/smoke.js.map +1 -0
  99. package/dist/core/extensions/plugins/trigger-eval.d.ts +136 -0
  100. package/dist/core/extensions/plugins/trigger-eval.d.ts.map +1 -0
  101. package/dist/core/extensions/plugins/trigger-eval.js +0 -0
  102. package/dist/core/extensions/plugins/trigger-eval.js.map +1 -0
  103. package/dist/core/package-manager.d.ts +6 -0
  104. package/dist/core/package-manager.d.ts.map +1 -1
  105. package/dist/core/package-manager.js.map +1 -1
  106. package/dist/core/resource-loader.d.ts +2 -0
  107. package/dist/core/resource-loader.d.ts.map +1 -1
  108. package/dist/core/resource-loader.js +15 -0
  109. package/dist/core/resource-loader.js.map +1 -1
  110. package/dist/core/settings-manager.d.ts +2 -2
  111. package/dist/core/settings-manager.d.ts.map +1 -1
  112. package/dist/core/settings-manager.js +3 -3
  113. package/dist/core/settings-manager.js.map +1 -1
  114. package/dist/core/settings-types.d.ts +1 -1
  115. package/dist/core/settings-types.d.ts.map +1 -1
  116. package/dist/core/settings-types.js.map +1 -1
  117. package/dist/core/skills.d.ts +9 -0
  118. package/dist/core/skills.d.ts.map +1 -1
  119. package/dist/core/skills.js +23 -3
  120. package/dist/core/skills.js.map +1 -1
  121. package/dist/core/tools/package-plugin.d.ts +19 -0
  122. package/dist/core/tools/package-plugin.d.ts.map +1 -0
  123. package/dist/core/tools/package-plugin.js +81 -0
  124. package/dist/core/tools/package-plugin.js.map +1 -0
  125. package/dist/core/tools/plugin-tool-names.d.ts +1 -0
  126. package/dist/core/tools/plugin-tool-names.d.ts.map +1 -1
  127. package/dist/core/tools/plugin-tool-names.js +6 -0
  128. package/dist/core/tools/plugin-tool-names.js.map +1 -1
  129. package/dist/core/tools/plugins.d.ts.map +1 -1
  130. package/dist/core/tools/plugins.js +48 -7
  131. package/dist/core/tools/plugins.js.map +1 -1
  132. package/dist/core/tools/propose-plugin.d.ts.map +1 -1
  133. package/dist/core/tools/propose-plugin.js +49 -21
  134. package/dist/core/tools/propose-plugin.js.map +1 -1
  135. package/dist/core/tools/webfetch.d.ts +3 -1
  136. package/dist/core/tools/webfetch.d.ts.map +1 -1
  137. package/dist/core/tools/webfetch.js +8 -2
  138. package/dist/core/tools/webfetch.js.map +1 -1
  139. package/dist/core/tools/websearch.d.ts +2 -0
  140. package/dist/core/tools/websearch.d.ts.map +1 -1
  141. package/dist/core/tools/websearch.js +2 -1
  142. package/dist/core/tools/websearch.js.map +1 -1
  143. package/dist/core/tools/webtools-shared.d.ts +23 -2
  144. package/dist/core/tools/webtools-shared.d.ts.map +1 -1
  145. package/dist/core/tools/webtools-shared.js +59 -9
  146. package/dist/core/tools/webtools-shared.js.map +1 -1
  147. package/dist/extensions/core/marketplace.d.ts +14 -3
  148. package/dist/extensions/core/marketplace.d.ts.map +1 -1
  149. package/dist/extensions/core/marketplace.js +77 -18
  150. package/dist/extensions/core/marketplace.js.map +1 -1
  151. package/dist/extensions/core/mcp-deferred.d.ts +39 -2
  152. package/dist/extensions/core/mcp-deferred.d.ts.map +1 -1
  153. package/dist/extensions/core/mcp-deferred.js +74 -10
  154. package/dist/extensions/core/mcp-deferred.js.map +1 -1
  155. package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
  156. package/dist/extensions/core/mcp-loader.js +52 -9
  157. package/dist/extensions/core/mcp-loader.js.map +1 -1
  158. package/dist/extensions/core/scaffold.d.ts +3 -3
  159. package/dist/extensions/core/scaffold.d.ts.map +1 -1
  160. package/dist/extensions/core/scaffold.js +9 -9
  161. package/dist/extensions/core/scaffold.js.map +1 -1
  162. package/dist/main.d.ts.map +1 -1
  163. package/dist/main.js +25 -7
  164. package/dist/main.js.map +1 -1
  165. package/dist/utils/tools-manager.d.ts +8 -0
  166. package/dist/utils/tools-manager.d.ts.map +1 -1
  167. package/dist/utils/tools-manager.js +12 -3
  168. package/dist/utils/tools-manager.js.map +1 -1
  169. package/docs/providers.md +11 -0
  170. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  171. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  172. package/examples/extensions/sandbox/package.json +1 -1
  173. package/examples/extensions/with-deps/package.json +1 -1
  174. package/package.json +5 -4
@@ -0,0 +1,56 @@
1
+ /**
2
+ * One index of everything the agent can *acquire* — MCP tools, skills, slash
3
+ * commands, subagents, and plugins available or installed.
4
+ *
5
+ * The problem this exists for: deferral already withholds MCP tool schemas, but
6
+ * the model still needs some way to find a withheld tool, and the only one it
7
+ * had was an exact name match against a catalog dumped in full into the
8
+ * resolver's description. Those two are locked together — an exact-only matcher
9
+ * *forces* the full dump, because a name you cannot guess is a tool you cannot
10
+ * reach. Give the matcher retrieval and the dump becomes optional, which is
11
+ * where the context saving actually lives.
12
+ *
13
+ * The producers are deliberately not coupled to it: each one hands over a flat
14
+ * list of documents for its own kind and knows nothing about retrieval. That is
15
+ * what lets skills and commands join later (§6.4 leaves them eager for now)
16
+ * without touching the search side.
17
+ *
18
+ * See docs/plugin-system-architecture.md §6.
19
+ */
20
+ export type CapabilityKind = "mcp-tool" | "skill" | "command" | "agent" | "plugin-available" | "plugin-installed";
21
+ export interface CapabilityDoc {
22
+ /** Unique and stable within a session; `<kind>:<name>` by convention. */
23
+ id: string;
24
+ kind: CapabilityKind;
25
+ /** How the model refers to it — a tool name, skill name, plugin id. */
26
+ name: string;
27
+ description: string;
28
+ /** Where it came from: MCP server, plugin id, marketplace name. */
29
+ source?: string;
30
+ /**
31
+ * Whether the expensive part (a JSON schema, a skill body) is currently
32
+ * withheld from context.
33
+ *
34
+ * Recorded rather than derived because it is the policy knob of §6.3: it says
35
+ * which entries retrieval is actually *for*. An eager capability is already
36
+ * visible to the model, so surfacing it in search results is a convenience;
37
+ * a deferred one is otherwise unreachable.
38
+ */
39
+ deferred: boolean;
40
+ }
41
+ /** Replace every document of `kind`. Producers call this on load and on reload. */
42
+ export declare function registerCapabilities(kind: CapabilityKind, docs: readonly CapabilityDoc[]): void;
43
+ /** Every registered document, in a stable order (kind, then id). */
44
+ export declare function getCapabilities(kinds?: readonly CapabilityKind[]): CapabilityDoc[];
45
+ /** Drop everything. Tests, and a full session reset. */
46
+ export declare function clearCapabilities(): void;
47
+ /**
48
+ * Content hash of the current capability set — the key a persistent index is
49
+ * stored under.
50
+ *
51
+ * Hashes the text that gets embedded, not the count: two sessions with the same
52
+ * tools should share an index, and one where a server changed a description
53
+ * should not silently reuse vectors describing the old one.
54
+ */
55
+ export declare function capabilitySetHash(docs?: readonly CapabilityDoc[]): string;
56
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/core/capabilities/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,MAAM,MAAM,cAAc,GAAG,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,GAAG,kBAAkB,GAAG,kBAAkB,CAAC;AAElH,MAAM,WAAW,aAAa;IAC7B,yEAAyE;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,cAAc,CAAC;IACrB,yEAAuE;IACvE,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;OAQG;IACH,QAAQ,EAAE,OAAO,CAAC;CAClB;AAWD,mFAAmF;AACnF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI,CAG/F;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,KAAK,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,aAAa,EAAE,CAQlF;AAED,wDAAwD;AACxD,wBAAgB,iBAAiB,IAAI,IAAI,CAExC;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,GAAE,SAAS,aAAa,EAAsB,GAAG,MAAM,CAI5F","sourcesContent":["/**\n * One index of everything the agent can *acquire* — MCP tools, skills, slash\n * commands, subagents, and plugins available or installed.\n *\n * The problem this exists for: deferral already withholds MCP tool schemas, but\n * the model still needs some way to find a withheld tool, and the only one it\n * had was an exact name match against a catalog dumped in full into the\n * resolver's description. Those two are locked together — an exact-only matcher\n * *forces* the full dump, because a name you cannot guess is a tool you cannot\n * reach. Give the matcher retrieval and the dump becomes optional, which is\n * where the context saving actually lives.\n *\n * The producers are deliberately not coupled to it: each one hands over a flat\n * list of documents for its own kind and knows nothing about retrieval. That is\n * what lets skills and commands join later (§6.4 leaves them eager for now)\n * without touching the search side.\n *\n * See docs/plugin-system-architecture.md §6.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport type CapabilityKind = \"mcp-tool\" | \"skill\" | \"command\" | \"agent\" | \"plugin-available\" | \"plugin-installed\";\n\nexport interface CapabilityDoc {\n\t/** Unique and stable within a session; `<kind>:<name>` by convention. */\n\tid: string;\n\tkind: CapabilityKind;\n\t/** How the model refers to it — a tool name, skill name, plugin id. */\n\tname: string;\n\tdescription: string;\n\t/** Where it came from: MCP server, plugin id, marketplace name. */\n\tsource?: string;\n\t/**\n\t * Whether the expensive part (a JSON schema, a skill body) is currently\n\t * withheld from context.\n\t *\n\t * Recorded rather than derived because it is the policy knob of §6.3: it says\n\t * which entries retrieval is actually *for*. An eager capability is already\n\t * visible to the model, so surfacing it in search results is a convenience;\n\t * a deferred one is otherwise unreachable.\n\t */\n\tdeferred: boolean;\n}\n\n/**\n * Registered documents, by kind.\n *\n * Keyed by kind rather than a flat map because a producer owns its whole kind:\n * the MCP loader knows every MCP tool there is, and on reload it should replace\n * that set, not merge into a pile where a removed server's tools linger.\n */\nconst byKind = new Map<CapabilityKind, CapabilityDoc[]>();\n\n/** Replace every document of `kind`. Producers call this on load and on reload. */\nexport function registerCapabilities(kind: CapabilityKind, docs: readonly CapabilityDoc[]): void {\n\tif (docs.length === 0) byKind.delete(kind);\n\telse byKind.set(kind, [...docs]);\n}\n\n/** Every registered document, in a stable order (kind, then id). */\nexport function getCapabilities(kinds?: readonly CapabilityKind[]): CapabilityDoc[] {\n\tconst wanted = kinds && kinds.length > 0 ? new Set(kinds) : undefined;\n\tconst out: CapabilityDoc[] = [];\n\tfor (const kind of [...byKind.keys()].sort()) {\n\t\tif (wanted && !wanted.has(kind)) continue;\n\t\tout.push(...(byKind.get(kind) ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)));\n\t}\n\treturn out;\n}\n\n/** Drop everything. Tests, and a full session reset. */\nexport function clearCapabilities(): void {\n\tbyKind.clear();\n}\n\n/**\n * Content hash of the current capability set — the key a persistent index is\n * stored under.\n *\n * Hashes the text that gets embedded, not the count: two sessions with the same\n * tools should share an index, and one where a server changed a description\n * should not silently reuse vectors describing the old one.\n */\nexport function capabilitySetHash(docs: readonly CapabilityDoc[] = getCapabilities()): string {\n\tconst h = createHash(\"sha256\");\n\tfor (const d of docs) h.update(`${d.id}\u0000${d.name}\u0000${d.description}\u0000`);\n\treturn h.digest(\"hex\").slice(0, 16);\n}\n"]}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../src/core/capabilities/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAyBzC;;;;;;GAMG;AACH,MAAM,MAAM,GAAG,IAAI,GAAG,EAAmC,CAAC;AAE1D,mFAAmF;AACnF,MAAM,UAAU,oBAAoB,CAAC,IAAoB,EAAE,IAA8B,EAAQ;IAChG,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;;QACtC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;AAAA,CACjC;AAED,oEAAoE;AACpE,MAAM,UAAU,eAAe,CAAC,KAAiC,EAAmB;IACnF,MAAM,MAAM,GAAG,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IACtE,MAAM,GAAG,GAAoB,EAAE,CAAC;IAChC,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;QAC9C,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,SAAS;QAC1C,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,GAAG,CAAC;AAAA,CACX;AAED,wDAAwD;AACxD,MAAM,UAAU,iBAAiB,GAAS;IACzC,MAAM,CAAC,KAAK,EAAE,CAAC;AAAA,CACf;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAI,GAA6B,eAAe,EAAE,EAAU;IAC7F,MAAM,CAAC,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC;IAC/B,KAAK,MAAM,CAAC,IAAI,IAAI;QAAE,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC;IACtE,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAAA,CACpC","sourcesContent":["/**\n * One index of everything the agent can *acquire* — MCP tools, skills, slash\n * commands, subagents, and plugins available or installed.\n *\n * The problem this exists for: deferral already withholds MCP tool schemas, but\n * the model still needs some way to find a withheld tool, and the only one it\n * had was an exact name match against a catalog dumped in full into the\n * resolver's description. Those two are locked together — an exact-only matcher\n * *forces* the full dump, because a name you cannot guess is a tool you cannot\n * reach. Give the matcher retrieval and the dump becomes optional, which is\n * where the context saving actually lives.\n *\n * The producers are deliberately not coupled to it: each one hands over a flat\n * list of documents for its own kind and knows nothing about retrieval. That is\n * what lets skills and commands join later (§6.4 leaves them eager for now)\n * without touching the search side.\n *\n * See docs/plugin-system-architecture.md §6.\n */\n\nimport { createHash } from \"node:crypto\";\n\nexport type CapabilityKind = \"mcp-tool\" | \"skill\" | \"command\" | \"agent\" | \"plugin-available\" | \"plugin-installed\";\n\nexport interface CapabilityDoc {\n\t/** Unique and stable within a session; `<kind>:<name>` by convention. */\n\tid: string;\n\tkind: CapabilityKind;\n\t/** How the model refers to it — a tool name, skill name, plugin id. */\n\tname: string;\n\tdescription: string;\n\t/** Where it came from: MCP server, plugin id, marketplace name. */\n\tsource?: string;\n\t/**\n\t * Whether the expensive part (a JSON schema, a skill body) is currently\n\t * withheld from context.\n\t *\n\t * Recorded rather than derived because it is the policy knob of §6.3: it says\n\t * which entries retrieval is actually *for*. An eager capability is already\n\t * visible to the model, so surfacing it in search results is a convenience;\n\t * a deferred one is otherwise unreachable.\n\t */\n\tdeferred: boolean;\n}\n\n/**\n * Registered documents, by kind.\n *\n * Keyed by kind rather than a flat map because a producer owns its whole kind:\n * the MCP loader knows every MCP tool there is, and on reload it should replace\n * that set, not merge into a pile where a removed server's tools linger.\n */\nconst byKind = new Map<CapabilityKind, CapabilityDoc[]>();\n\n/** Replace every document of `kind`. Producers call this on load and on reload. */\nexport function registerCapabilities(kind: CapabilityKind, docs: readonly CapabilityDoc[]): void {\n\tif (docs.length === 0) byKind.delete(kind);\n\telse byKind.set(kind, [...docs]);\n}\n\n/** Every registered document, in a stable order (kind, then id). */\nexport function getCapabilities(kinds?: readonly CapabilityKind[]): CapabilityDoc[] {\n\tconst wanted = kinds && kinds.length > 0 ? new Set(kinds) : undefined;\n\tconst out: CapabilityDoc[] = [];\n\tfor (const kind of [...byKind.keys()].sort()) {\n\t\tif (wanted && !wanted.has(kind)) continue;\n\t\tout.push(...(byKind.get(kind) ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)));\n\t}\n\treturn out;\n}\n\n/** Drop everything. Tests, and a full session reset. */\nexport function clearCapabilities(): void {\n\tbyKind.clear();\n}\n\n/**\n * Content hash of the current capability set — the key a persistent index is\n * stored under.\n *\n * Hashes the text that gets embedded, not the count: two sessions with the same\n * tools should share an index, and one where a server changed a description\n * should not silently reuse vectors describing the old one.\n */\nexport function capabilitySetHash(docs: readonly CapabilityDoc[] = getCapabilities()): string {\n\tconst h = createHash(\"sha256\");\n\tfor (const d of docs) h.update(`${d.id}\u0000${d.name}\u0000${d.description}\u0000`);\n\treturn h.digest(\"hex\").slice(0, 16);\n}\n"]}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Capability retrieval: BM25 and dense, fused by RRF.
3
+ *
4
+ * Hybrid rather than dense-only because capability descriptions are short.
5
+ * Pure dense retrieval over eight-word strings is weak, and — more decisively —
6
+ * an exact name lookup has to stay exact: `create_pull_request` must find
7
+ * `mcp_github_create_pull_request` every time, which is a lexical guarantee, not
8
+ * a nearest-neighbour one. Fusion gets both: the lexical leg answers "the tool
9
+ * called roughly this", the dense leg "something that does this".
10
+ *
11
+ * Reuses the repo search's {@link rrfFuse} rather than reimplementing fusion.
12
+ * The tie-breaking and duplicate handling there are already worked out, and a
13
+ * second, subtly different fusion is how two retrieval paths drift apart.
14
+ */
15
+ import { type CapabilityDoc, type CapabilityKind } from "./registry.js";
16
+ export interface CapabilityHit {
17
+ doc: CapabilityDoc;
18
+ /** Fused score. Comparable within one result set only. */
19
+ score: number;
20
+ /** Which legs found it — useful when explaining a surprising ranking. */
21
+ matchedBy: Array<"lexical" | "dense">;
22
+ }
23
+ export interface CapabilitySearchOptions {
24
+ /** Restrict to these kinds. Omit for everything. */
25
+ kinds?: readonly CapabilityKind[];
26
+ /** Results to return. */
27
+ limit?: number;
28
+ /** Restrict to capabilities whose expensive part is currently withheld. */
29
+ deferredOnly?: boolean;
30
+ }
31
+ export interface CapabilitySearchResult {
32
+ hits: CapabilityHit[];
33
+ /** How the answer was produced, so a caller can say "lexical only" honestly. */
34
+ legs: Array<"lexical" | "dense">;
35
+ }
36
+ /** Drop the cached lexical index. Tests, and anything that rewrites the registry wholesale. */
37
+ export declare function resetCapabilitySearch(): void;
38
+ /**
39
+ * Find capabilities matching `query`.
40
+ *
41
+ * Over-fetches from each leg before fusing: RRF works on rank, so a document
42
+ * ranked 8th by one retriever and absent from the other still deserves to be
43
+ * considered — truncating each list to the final `limit` first would throw that
44
+ * away before fusion could use it.
45
+ */
46
+ export declare function searchCapabilities(query: string, options?: CapabilitySearchOptions): Promise<CapabilitySearchResult>;
47
+ //# sourceMappingURL=search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/core/capabilities/search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,cAAc,EAAsC,MAAM,eAAe,CAAC;AAE5G,MAAM,WAAW,aAAa;IAC7B,GAAG,EAAE,aAAa,CAAC;IACnB,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,2EAAyE;IACzE,SAAS,EAAE,KAAK,CAAC,SAAS,GAAG,OAAO,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,uBAAuB;IACvC,oDAAoD;IACpD,KAAK,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IAClC,yBAAyB;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,YAAY,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,aAAa,EAAE,CAAC;IACtB,gFAAgF;IAChF,IAAI,EAAE,KAAK,CAAC,SAAS,GAAG,OAAO,CAAC,CAAC;CACjC;AAWD,+FAA+F;AAC/F,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACvC,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,uBAA4B,GACnC,OAAO,CAAC,sBAAsB,CAAC,CAuCjC","sourcesContent":["/**\n * Capability retrieval: BM25 and dense, fused by RRF.\n *\n * Hybrid rather than dense-only because capability descriptions are short.\n * Pure dense retrieval over eight-word strings is weak, and — more decisively —\n * an exact name lookup has to stay exact: `create_pull_request` must find\n * `mcp_github_create_pull_request` every time, which is a lexical guarantee, not\n * a nearest-neighbour one. Fusion gets both: the lexical leg answers \"the tool\n * called roughly this\", the dense leg \"something that does this\".\n *\n * Reuses the repo search's {@link rrfFuse} rather than reimplementing fusion.\n * The tie-breaking and duplicate handling there are already worked out, and a\n * second, subtly different fusion is how two retrieval paths drift apart.\n */\n\nimport { rrfFuse } from \"../search/rrf.js\";\nimport type { RankedHit } from \"../search/types.js\";\nimport { denseSearch, denseState } from \"./dense.js\";\nimport { LexicalIndex } from \"./lexical.js\";\nimport { type CapabilityDoc, type CapabilityKind, capabilitySetHash, getCapabilities } from \"./registry.js\";\n\nexport interface CapabilityHit {\n\tdoc: CapabilityDoc;\n\t/** Fused score. Comparable within one result set only. */\n\tscore: number;\n\t/** Which legs found it — useful when explaining a surprising ranking. */\n\tmatchedBy: Array<\"lexical\" | \"dense\">;\n}\n\nexport interface CapabilitySearchOptions {\n\t/** Restrict to these kinds. Omit for everything. */\n\tkinds?: readonly CapabilityKind[];\n\t/** Results to return. */\n\tlimit?: number;\n\t/** Restrict to capabilities whose expensive part is currently withheld. */\n\tdeferredOnly?: boolean;\n}\n\nexport interface CapabilitySearchResult {\n\thits: CapabilityHit[];\n\t/** How the answer was produced, so a caller can say \"lexical only\" honestly. */\n\tlegs: Array<\"lexical\" | \"dense\">;\n}\n\n/** Cached index, rebuilt when the capability set changes. */\nlet cached: { hash: string; index: LexicalIndex; docs: CapabilityDoc[] } | undefined;\n\nfunction lexicalIndexFor(docs: CapabilityDoc[]): LexicalIndex {\n\tconst hash = capabilitySetHash(docs);\n\tif (cached?.hash !== hash) cached = { hash, index: new LexicalIndex(docs), docs };\n\treturn cached.index;\n}\n\n/** Drop the cached lexical index. Tests, and anything that rewrites the registry wholesale. */\nexport function resetCapabilitySearch(): void {\n\tcached = undefined;\n}\n\n/**\n * Find capabilities matching `query`.\n *\n * Over-fetches from each leg before fusing: RRF works on rank, so a document\n * ranked 8th by one retriever and absent from the other still deserves to be\n * considered — truncating each list to the final `limit` first would throw that\n * away before fusion could use it.\n */\nexport async function searchCapabilities(\n\tquery: string,\n\toptions: CapabilitySearchOptions = {},\n): Promise<CapabilitySearchResult> {\n\tconst limit = options.limit ?? 10;\n\tlet docs = getCapabilities(options.kinds);\n\tif (options.deferredOnly) docs = docs.filter((d) => d.deferred);\n\tif (docs.length === 0 || !query.trim()) return { hits: [], legs: [] };\n\n\tconst byId = new Map(docs.map((d) => [d.id, d]));\n\tconst fetchK = Math.max(limit * 3, 20);\n\n\tconst lexical = lexicalIndexFor(docs).search(query, fetchK);\n\tconst dense = denseState().status === \"ready\" ? await denseSearch(query, fetchK) : [];\n\n\tconst legs: Array<\"lexical\" | \"dense\"> = [];\n\tconst lists: RankedHit[][] = [];\n\tif (lexical.length > 0) {\n\t\tlegs.push(\"lexical\");\n\t\tlists.push(lexical.map((h, i) => ({ id: h.id, rank: i + 1, score: h.score, source: \"bm25\" as const })));\n\t}\n\t// The dense store spans every registered capability, so its hits can fall\n\t// outside a kind- or deferred-filtered set; drop those before ranking rather\n\t// than leaving gaps in the rank sequence RRF expects.\n\tconst denseInScope = dense.filter((h) => byId.has(h.id));\n\tif (denseInScope.length > 0) {\n\t\tlegs.push(\"dense\");\n\t\tlists.push(denseInScope.map((h, i) => ({ id: h.id, rank: i + 1, score: h.score, source: \"embed\" as const })));\n\t}\n\tif (lists.length === 0) return { hits: [], legs: [] };\n\n\tconst hits: CapabilityHit[] = [];\n\tfor (const fused of rrfFuse(lists)) {\n\t\tconst doc = byId.get(fused.id);\n\t\tif (!doc) continue;\n\t\tconst matchedBy: Array<\"lexical\" | \"dense\"> = [];\n\t\tif (fused.ranks.bm25 !== undefined) matchedBy.push(\"lexical\");\n\t\tif (fused.ranks.embed !== undefined) matchedBy.push(\"dense\");\n\t\thits.push({ doc, score: fused.rrfScore, matchedBy });\n\t\tif (hits.length >= limit) break;\n\t}\n\treturn { hits, legs };\n}\n"]}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Capability retrieval: BM25 and dense, fused by RRF.
3
+ *
4
+ * Hybrid rather than dense-only because capability descriptions are short.
5
+ * Pure dense retrieval over eight-word strings is weak, and — more decisively —
6
+ * an exact name lookup has to stay exact: `create_pull_request` must find
7
+ * `mcp_github_create_pull_request` every time, which is a lexical guarantee, not
8
+ * a nearest-neighbour one. Fusion gets both: the lexical leg answers "the tool
9
+ * called roughly this", the dense leg "something that does this".
10
+ *
11
+ * Reuses the repo search's {@link rrfFuse} rather than reimplementing fusion.
12
+ * The tie-breaking and duplicate handling there are already worked out, and a
13
+ * second, subtly different fusion is how two retrieval paths drift apart.
14
+ */
15
+ import { rrfFuse } from "../search/rrf.js";
16
+ import { denseSearch, denseState } from "./dense.js";
17
+ import { LexicalIndex } from "./lexical.js";
18
+ import { capabilitySetHash, getCapabilities } from "./registry.js";
19
+ /** Cached index, rebuilt when the capability set changes. */
20
+ let cached;
21
+ function lexicalIndexFor(docs) {
22
+ const hash = capabilitySetHash(docs);
23
+ if (cached?.hash !== hash)
24
+ cached = { hash, index: new LexicalIndex(docs), docs };
25
+ return cached.index;
26
+ }
27
+ /** Drop the cached lexical index. Tests, and anything that rewrites the registry wholesale. */
28
+ export function resetCapabilitySearch() {
29
+ cached = undefined;
30
+ }
31
+ /**
32
+ * Find capabilities matching `query`.
33
+ *
34
+ * Over-fetches from each leg before fusing: RRF works on rank, so a document
35
+ * ranked 8th by one retriever and absent from the other still deserves to be
36
+ * considered — truncating each list to the final `limit` first would throw that
37
+ * away before fusion could use it.
38
+ */
39
+ export async function searchCapabilities(query, options = {}) {
40
+ const limit = options.limit ?? 10;
41
+ let docs = getCapabilities(options.kinds);
42
+ if (options.deferredOnly)
43
+ docs = docs.filter((d) => d.deferred);
44
+ if (docs.length === 0 || !query.trim())
45
+ return { hits: [], legs: [] };
46
+ const byId = new Map(docs.map((d) => [d.id, d]));
47
+ const fetchK = Math.max(limit * 3, 20);
48
+ const lexical = lexicalIndexFor(docs).search(query, fetchK);
49
+ const dense = denseState().status === "ready" ? await denseSearch(query, fetchK) : [];
50
+ const legs = [];
51
+ const lists = [];
52
+ if (lexical.length > 0) {
53
+ legs.push("lexical");
54
+ lists.push(lexical.map((h, i) => ({ id: h.id, rank: i + 1, score: h.score, source: "bm25" })));
55
+ }
56
+ // The dense store spans every registered capability, so its hits can fall
57
+ // outside a kind- or deferred-filtered set; drop those before ranking rather
58
+ // than leaving gaps in the rank sequence RRF expects.
59
+ const denseInScope = dense.filter((h) => byId.has(h.id));
60
+ if (denseInScope.length > 0) {
61
+ legs.push("dense");
62
+ lists.push(denseInScope.map((h, i) => ({ id: h.id, rank: i + 1, score: h.score, source: "embed" })));
63
+ }
64
+ if (lists.length === 0)
65
+ return { hits: [], legs: [] };
66
+ const hits = [];
67
+ for (const fused of rrfFuse(lists)) {
68
+ const doc = byId.get(fused.id);
69
+ if (!doc)
70
+ continue;
71
+ const matchedBy = [];
72
+ if (fused.ranks.bm25 !== undefined)
73
+ matchedBy.push("lexical");
74
+ if (fused.ranks.embed !== undefined)
75
+ matchedBy.push("dense");
76
+ hits.push({ doc, score: fused.rrfScore, matchedBy });
77
+ if (hits.length >= limit)
78
+ break;
79
+ }
80
+ return { hits, legs };
81
+ }
82
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.js","sourceRoot":"","sources":["../../../src/core/capabilities/search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAE3C,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAA2C,iBAAiB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAyB5G,6DAA6D;AAC7D,IAAI,MAAgF,CAAC;AAErF,SAAS,eAAe,CAAC,IAAqB,EAAgB;IAC7D,MAAM,IAAI,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC;IACrC,IAAI,MAAM,EAAE,IAAI,KAAK,IAAI;QAAE,MAAM,GAAG,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,YAAY,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC;IAClF,OAAO,MAAM,CAAC,KAAK,CAAC;AAAA,CACpB;AAED,+FAA+F;AAC/F,MAAM,UAAU,qBAAqB,GAAS;IAC7C,MAAM,GAAG,SAAS,CAAC;AAAA,CACnB;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACvC,KAAa,EACb,OAAO,GAA4B,EAAE,EACH;IAClC,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC;IAClC,IAAI,IAAI,GAAG,eAAe,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC1C,IAAI,OAAO,CAAC,YAAY;QAAE,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;IAChE,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;IAEtE,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;IAEvC,MAAM,OAAO,GAAG,eAAe,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IAC5D,MAAM,KAAK,GAAG,UAAU,EAAE,CAAC,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,MAAM,WAAW,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAEtF,MAAM,IAAI,GAA+B,EAAE,CAAC;IAC5C,MAAM,KAAK,GAAkB,EAAE,CAAC;IAChC,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACrB,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,MAAe,EAAE,CAAC,CAAC,CAAC,CAAC;IACzG,CAAC;IACD,0EAA0E;IAC1E,6EAA6E;IAC7E,sDAAsD;IACtD,MAAM,YAAY,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACzD,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC7B,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACnB,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,OAAgB,EAAE,CAAC,CAAC,CAAC,CAAC;IAC/G,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;IAEtD,MAAM,IAAI,GAAoB,EAAE,CAAC;IACjC,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACpC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC/B,IAAI,CAAC,GAAG;YAAE,SAAS;QACnB,MAAM,SAAS,GAA+B,EAAE,CAAC;QACjD,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS;YAAE,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC9D,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,KAAK,SAAS;YAAE,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC7D,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,CAAC,QAAQ,EAAE,SAAS,EAAE,CAAC,CAAC;QACrD,IAAI,IAAI,CAAC,MAAM,IAAI,KAAK;YAAE,MAAM;IACjC,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AAAA,CACtB","sourcesContent":["/**\n * Capability retrieval: BM25 and dense, fused by RRF.\n *\n * Hybrid rather than dense-only because capability descriptions are short.\n * Pure dense retrieval over eight-word strings is weak, and — more decisively —\n * an exact name lookup has to stay exact: `create_pull_request` must find\n * `mcp_github_create_pull_request` every time, which is a lexical guarantee, not\n * a nearest-neighbour one. Fusion gets both: the lexical leg answers \"the tool\n * called roughly this\", the dense leg \"something that does this\".\n *\n * Reuses the repo search's {@link rrfFuse} rather than reimplementing fusion.\n * The tie-breaking and duplicate handling there are already worked out, and a\n * second, subtly different fusion is how two retrieval paths drift apart.\n */\n\nimport { rrfFuse } from \"../search/rrf.js\";\nimport type { RankedHit } from \"../search/types.js\";\nimport { denseSearch, denseState } from \"./dense.js\";\nimport { LexicalIndex } from \"./lexical.js\";\nimport { type CapabilityDoc, type CapabilityKind, capabilitySetHash, getCapabilities } from \"./registry.js\";\n\nexport interface CapabilityHit {\n\tdoc: CapabilityDoc;\n\t/** Fused score. Comparable within one result set only. */\n\tscore: number;\n\t/** Which legs found it — useful when explaining a surprising ranking. */\n\tmatchedBy: Array<\"lexical\" | \"dense\">;\n}\n\nexport interface CapabilitySearchOptions {\n\t/** Restrict to these kinds. Omit for everything. */\n\tkinds?: readonly CapabilityKind[];\n\t/** Results to return. */\n\tlimit?: number;\n\t/** Restrict to capabilities whose expensive part is currently withheld. */\n\tdeferredOnly?: boolean;\n}\n\nexport interface CapabilitySearchResult {\n\thits: CapabilityHit[];\n\t/** How the answer was produced, so a caller can say \"lexical only\" honestly. */\n\tlegs: Array<\"lexical\" | \"dense\">;\n}\n\n/** Cached index, rebuilt when the capability set changes. */\nlet cached: { hash: string; index: LexicalIndex; docs: CapabilityDoc[] } | undefined;\n\nfunction lexicalIndexFor(docs: CapabilityDoc[]): LexicalIndex {\n\tconst hash = capabilitySetHash(docs);\n\tif (cached?.hash !== hash) cached = { hash, index: new LexicalIndex(docs), docs };\n\treturn cached.index;\n}\n\n/** Drop the cached lexical index. Tests, and anything that rewrites the registry wholesale. */\nexport function resetCapabilitySearch(): void {\n\tcached = undefined;\n}\n\n/**\n * Find capabilities matching `query`.\n *\n * Over-fetches from each leg before fusing: RRF works on rank, so a document\n * ranked 8th by one retriever and absent from the other still deserves to be\n * considered — truncating each list to the final `limit` first would throw that\n * away before fusion could use it.\n */\nexport async function searchCapabilities(\n\tquery: string,\n\toptions: CapabilitySearchOptions = {},\n): Promise<CapabilitySearchResult> {\n\tconst limit = options.limit ?? 10;\n\tlet docs = getCapabilities(options.kinds);\n\tif (options.deferredOnly) docs = docs.filter((d) => d.deferred);\n\tif (docs.length === 0 || !query.trim()) return { hits: [], legs: [] };\n\n\tconst byId = new Map(docs.map((d) => [d.id, d]));\n\tconst fetchK = Math.max(limit * 3, 20);\n\n\tconst lexical = lexicalIndexFor(docs).search(query, fetchK);\n\tconst dense = denseState().status === \"ready\" ? await denseSearch(query, fetchK) : [];\n\n\tconst legs: Array<\"lexical\" | \"dense\"> = [];\n\tconst lists: RankedHit[][] = [];\n\tif (lexical.length > 0) {\n\t\tlegs.push(\"lexical\");\n\t\tlists.push(lexical.map((h, i) => ({ id: h.id, rank: i + 1, score: h.score, source: \"bm25\" as const })));\n\t}\n\t// The dense store spans every registered capability, so its hits can fall\n\t// outside a kind- or deferred-filtered set; drop those before ranking rather\n\t// than leaving gaps in the rank sequence RRF expects.\n\tconst denseInScope = dense.filter((h) => byId.has(h.id));\n\tif (denseInScope.length > 0) {\n\t\tlegs.push(\"dense\");\n\t\tlists.push(denseInScope.map((h, i) => ({ id: h.id, rank: i + 1, score: h.score, source: \"embed\" as const })));\n\t}\n\tif (lists.length === 0) return { hits: [], legs: [] };\n\n\tconst hits: CapabilityHit[] = [];\n\tfor (const fused of rrfFuse(lists)) {\n\t\tconst doc = byId.get(fused.id);\n\t\tif (!doc) continue;\n\t\tconst matchedBy: Array<\"lexical\" | \"dense\"> = [];\n\t\tif (fused.ranks.bm25 !== undefined) matchedBy.push(\"lexical\");\n\t\tif (fused.ranks.embed !== undefined) matchedBy.push(\"dense\");\n\t\thits.push({ doc, score: fused.rrfScore, matchedBy });\n\t\tif (hits.length >= limit) break;\n\t}\n\treturn { hits, legs };\n}\n"]}
@@ -21,15 +21,39 @@ export declare function loadExtensions(paths: string[], cwd: string, eventBus?:
21
21
  * Discover and load extensions from standard locations.
22
22
  */
23
23
  export declare function discoverAndLoadExtensions(configuredPaths: string[], cwd: string, agentDir?: string, eventBus?: EventBus): Promise<LoadExtensionsResult>;
24
+ /**
25
+ * Whether a discovered plugin came with the repository rather than from the user.
26
+ *
27
+ * Scoped to `<cwd>/.claude/skills` — the vendor convention for repo-committed,
28
+ * collaborator-shared plugins, which Claude Code puts behind a workspace trust
29
+ * dialog. The other project paths (`.agents/plugins`, `.hoocode/plugins`) are
30
+ * hoocode's own former install homes, holding plugins the user installed
31
+ * deliberately, so treating those as untrusted would break existing setups.
32
+ */
33
+ export declare function isProjectSuppliedPlugin(pluginRoot: string, cwd: string): boolean;
24
34
  /**
25
35
  * Standard plugin discovery directories, highest precedence first.
26
36
  *
27
37
  * `.agents/plugins/` is the cross-vendor, primary home and is listed ahead of the
28
38
  * `.hoocode/plugins/` fallback at each scope, so an `.agents`-installed plugin
29
39
  * wins over a same-id `.hoocode` one (discoverPlugins is first-wins by id).
30
- * Project scope beats global. The global `.agents` sibling lives next to the
31
- * agent dir (`~/.agents` alongside `~/.hoocode`), so it stays parameterized on
32
- * `agentDir` rather than hardcoding the home directory.
40
+ * Project scope beats global. The global surfaces live next to the agent dir
41
+ * (`~/.agents`, `~/.claude` alongside `~/.hoocode`), so they stay parameterized
42
+ * on `agentDir` rather than hardcoding the home directory.
43
+ *
44
+ * Two of these are *production homes* for plugins hoocode authored, and two are
45
+ * skills directories:
46
+ *
47
+ * - `<cwd>/.agents/plugins` is the legacy project-local install home. Nothing
48
+ * writes there any more; it is read so plugins installed by older versions
49
+ * keep working.
50
+ * - `.claude/skills` (project and personal) implements Claude Code's
51
+ * skills-directory plugins: a folder there carrying `.claude-plugin/plugin.json`
52
+ * is a plugin, and a folder with only a `SKILL.md` stays a plain skill —
53
+ * `parsePluginDir` returns null for the latter, which is exactly the vendor's
54
+ * own rule, so no special-casing is needed here.
55
+ *
56
+ * See docs/plugin-system-architecture.md §5.3 and §5.7.
33
57
  */
34
58
  export declare function defaultPluginDirs(cwd: string, agentDir?: string): string[];
35
59
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../../src/core/extensions/loader.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAuBH,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAMhE,OAAO,KAAK,EACX,SAAS,EAET,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EAKpB,MAAM,YAAY,CAAC;AA8FpB;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,gBAAgB,CA+CzD;AA4OD;;GAEG;AACH,wBAAsB,wBAAwB,CAC7C,OAAO,EAAE,gBAAgB,EACzB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,gBAAgB,EACzB,aAAa,SAAa,EAC1B,WAAW,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,SAAS,CAAC,CAepB;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAwBrH;AAkHD;;GAEG;AACH,wBAAsB,yBAAyB,CAC9C,eAAe,EAAE,MAAM,EAAE,EACzB,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAsB,EAChC,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,oBAAoB,CAAC,CAkD/B;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,MAAM,EAAE,CAQzF;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAChC,UAAU,EAAE,MAAM,EAAE,EACpB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,gBAAgB,GACvB,OAAO,CAAC;IAAE,UAAU,EAAE,SAAS,EAAE,CAAC;IAAC,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,CAyBtF","sourcesContent":["/**\n * Extension loader - loads TypeScript extension modules using jiti.\n *\n */\n\nimport * as fs from \"node:fs\";\nimport { createRequire } from \"node:module\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport * as _bundledPiAgentCore from \"@kolisachint/hoocode-agent-core\";\nimport * as _bundledPiAi from \"@kolisachint/hoocode-ai\";\nimport * as _bundledPiAiOauth from \"@kolisachint/hoocode-ai/oauth\";\nimport type { KeyId } from \"@kolisachint/hoocode-tui\";\nimport * as _bundledPiTui from \"@kolisachint/hoocode-tui\";\nimport { createJiti } from \"jiti/static\";\n// Static imports of packages that extensions may use.\n// These MUST be static so Bun bundles them into the compiled binary.\n// The virtualModules option then makes them available to extensions.\nimport * as _bundledTypebox from \"typebox\";\nimport * as _bundledTypeboxCompile from \"typebox/compile\";\nimport * as _bundledTypeboxValue from \"typebox/value\";\nimport { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from \"../../config.js\";\n// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,\n// avoiding a circular dependency. Extensions can import from @kolisachint/hoocode-agent.\nimport * as _bundledPiCodingAgent from \"../../index.js\";\nimport { createEventBus, type EventBus } from \"../event-bus.js\";\nimport type { ExecOptions } from \"../exec.js\";\nimport { execCommand } from \"../exec.js\";\nimport { clearExtensionMcpServers } from \"../extension-mcp-servers.js\";\nimport { createSyntheticSourceInfo } from \"../source-info.js\";\nimport { buildPluginFactory, discoverPlugins } from \"./plugins/index.js\";\nimport type {\n\tExtension,\n\tExtensionAPI,\n\tExtensionFactory,\n\tExtensionRuntime,\n\tLoadExtensionsResult,\n\tMessageRenderer,\n\tProviderConfig,\n\tRegisteredCommand,\n\tToolDefinition,\n} from \"./types.js\";\n\n/** Modules available to extensions via virtualModules (for compiled Bun binary) */\nconst VIRTUAL_MODULES: Record<string, unknown> = {\n\ttypebox: _bundledTypebox,\n\t\"typebox/compile\": _bundledTypeboxCompile,\n\t\"typebox/value\": _bundledTypeboxValue,\n\t\"@sinclair/typebox\": _bundledTypebox,\n\t\"@sinclair/typebox/compile\": _bundledTypeboxCompile,\n\t\"@sinclair/typebox/value\": _bundledTypeboxValue,\n\t\"@kolisachint/hoocode-agent-core\": _bundledPiAgentCore,\n\t\"@kolisachint/hoocode-tui\": _bundledPiTui,\n\t\"@kolisachint/hoocode-ai\": _bundledPiAi,\n\t\"@kolisachint/hoocode-ai/oauth\": _bundledPiAiOauth,\n\t\"@kolisachint/hoocode-agent\": _bundledPiCodingAgent,\n};\n\nconst require = createRequire(import.meta.url);\n\n/**\n * Get aliases for jiti (used in Node.js/development mode).\n * In Bun binary mode, virtualModules is used instead.\n */\nlet _aliases: Record<string, string> | null = null;\n\nfunction getAliases(): Record<string, string> {\n\tif (_aliases) return _aliases;\n\n\tconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\tconst packageIndex = path.resolve(__dirname, \"../..\", \"index.js\");\n\n\tconst typeboxEntry = require.resolve(\"typebox\");\n\tconst typeboxCompileEntry = require.resolve(\"typebox/compile\");\n\tconst typeboxValueEntry = require.resolve(\"typebox/value\");\n\n\tconst packagesRoot = path.resolve(__dirname, \"../../../../\");\n\tconst resolveWorkspaceOrImport = (workspaceRelativePath: string, specifier: string): string => {\n\t\tconst workspacePath = path.join(packagesRoot, workspaceRelativePath);\n\t\tif (fs.existsSync(workspacePath)) {\n\t\t\treturn workspacePath;\n\t\t}\n\t\treturn fileURLToPath(import.meta.resolve(specifier));\n\t};\n\n\tconst hooCodingAgentEntry = packageIndex;\n\tconst hooAgentCoreEntry = resolveWorkspaceOrImport(\"agent/dist/index.js\", \"@kolisachint/hoocode-agent-core\");\n\tconst hooTuiEntry = resolveWorkspaceOrImport(\"tui/dist/index.js\", \"@kolisachint/hoocode-tui\");\n\tconst hooAiEntry = resolveWorkspaceOrImport(\"ai/dist/index.js\", \"@kolisachint/hoocode-ai\");\n\tconst hooAiOauthEntry = resolveWorkspaceOrImport(\"ai/dist/oauth.js\", \"@kolisachint/hoocode-ai/oauth\");\n\n\t_aliases = {\n\t\t\"@kolisachint/hoocode-agent\": hooCodingAgentEntry,\n\t\t\"@kolisachint/hoocode-agent-core\": hooAgentCoreEntry,\n\t\t\"@kolisachint/hoocode-tui\": hooTuiEntry,\n\t\t\"@kolisachint/hoocode-ai\": hooAiEntry,\n\t\t\"@kolisachint/hoocode-ai/oauth\": hooAiOauthEntry,\n\t\ttypebox: typeboxEntry,\n\t\t\"typebox/compile\": typeboxCompileEntry,\n\t\t\"typebox/value\": typeboxValueEntry,\n\t\t\"@sinclair/typebox\": typeboxEntry,\n\t\t\"@sinclair/typebox/compile\": typeboxCompileEntry,\n\t\t\"@sinclair/typebox/value\": typeboxValueEntry,\n\t};\n\n\treturn _aliases;\n}\n\nconst UNICODE_SPACES = /[\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000]/g;\n\nfunction normalizeUnicodeSpaces(str: string): string {\n\treturn str.replace(UNICODE_SPACES, \" \");\n}\n\nfunction expandPath(p: string): string {\n\tconst normalized = normalizeUnicodeSpaces(p);\n\tif (normalized.startsWith(\"~/\")) {\n\t\treturn path.join(os.homedir(), normalized.slice(2));\n\t}\n\tif (normalized.startsWith(\"~\")) {\n\t\treturn path.join(os.homedir(), normalized.slice(1));\n\t}\n\treturn normalized;\n}\n\nfunction resolvePath(extPath: string, cwd: string): string {\n\tconst expanded = expandPath(extPath);\n\tif (path.isAbsolute(expanded)) {\n\t\treturn expanded;\n\t}\n\treturn path.resolve(cwd, expanded);\n}\n\ntype HandlerFn = (...args: unknown[]) => Promise<unknown>;\n\n/**\n * Create a runtime with throwing stubs for action methods.\n * Runner.bindCore() replaces these with real implementations.\n */\nexport function createExtensionRuntime(): ExtensionRuntime {\n\tconst notInitialized = () => {\n\t\tthrow new Error(\"Extension runtime not initialized. Action methods cannot be called during extension loading.\");\n\t};\n\tconst state: { staleMessage?: string } = {};\n\tconst assertActive = () => {\n\t\tif (state.staleMessage) {\n\t\t\tthrow new Error(state.staleMessage);\n\t\t}\n\t};\n\n\tconst runtime: ExtensionRuntime = {\n\t\tsendMessage: notInitialized,\n\t\tsendUserMessage: notInitialized,\n\t\tappendEntry: notInitialized,\n\t\tsetSessionName: notInitialized,\n\t\tgetSessionName: notInitialized,\n\t\tsetLabel: notInitialized,\n\t\tgetActiveTools: notInitialized,\n\t\tgetAllTools: notInitialized,\n\t\tsetActiveTools: notInitialized,\n\t\t// registerTool() is valid during extension load; refresh is only needed post-bind.\n\t\trefreshTools: () => {},\n\t\tgetCommands: notInitialized,\n\t\tsetModel: () => Promise.reject(new Error(\"Extension runtime not initialized\")),\n\t\tgetThinkingLevel: notInitialized,\n\t\tsetThinkingLevel: notInitialized,\n\t\tflagValues: new Map(),\n\t\tpendingProviderRegistrations: [],\n\t\tmodeSearchPaths: [],\n\t\tassertActive,\n\t\tinvalidate: (message) => {\n\t\t\tstate.staleMessage ??=\n\t\t\t\tmessage ??\n\t\t\t\t\"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().\";\n\t\t},\n\t\t// Pre-bind: queue registrations so bindCore() can flush them once the\n\t\t// model registry is available. bindCore() replaces both with direct calls.\n\t\tregisterProvider: (name, config, extensionPath = \"<unknown>\") => {\n\t\t\truntime.pendingProviderRegistrations.push({ name, config, extensionPath });\n\t\t},\n\t\tunregisterProvider: (name) => {\n\t\t\truntime.pendingProviderRegistrations = runtime.pendingProviderRegistrations.filter((r) => r.name !== name);\n\t\t},\n\t};\n\n\treturn runtime;\n}\n\n/**\n * Create the ExtensionAPI for an extension.\n * Registration methods write to the extension object.\n * Action methods delegate to the shared runtime.\n */\nfunction createExtensionAPI(\n\textension: Extension,\n\truntime: ExtensionRuntime,\n\tcwd: string,\n\teventBus: EventBus,\n): ExtensionAPI {\n\tconst api = {\n\t\t// Registration methods - write to extension\n\t\ton(event: string, handler: HandlerFn): void {\n\t\t\truntime.assertActive();\n\t\t\tconst list = extension.handlers.get(event) ?? [];\n\t\t\tlist.push(handler);\n\t\t\textension.handlers.set(event, list);\n\t\t},\n\n\t\tregisterTool(tool: ToolDefinition): void {\n\t\t\truntime.assertActive();\n\t\t\textension.tools.set(tool.name, {\n\t\t\t\tdefinition: tool,\n\t\t\t\tsourceInfo: extension.sourceInfo,\n\t\t\t});\n\t\t\truntime.refreshTools();\n\t\t},\n\n\t\tregisterCommand(name: string, options: Omit<RegisteredCommand, \"name\" | \"sourceInfo\">): void {\n\t\t\truntime.assertActive();\n\t\t\textension.commands.set(name, {\n\t\t\t\tname,\n\t\t\t\tsourceInfo: extension.sourceInfo,\n\t\t\t\t...options,\n\t\t\t});\n\t\t},\n\n\t\tregisterShortcut(\n\t\t\tshortcut: KeyId,\n\t\t\toptions: {\n\t\t\t\tdescription?: string;\n\t\t\t\thandler: (ctx: import(\"./types.js\").ExtensionContext) => Promise<void> | void;\n\t\t\t},\n\t\t): void {\n\t\t\truntime.assertActive();\n\t\t\textension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options });\n\t\t},\n\n\t\tregisterFlag(\n\t\t\tname: string,\n\t\t\toptions: { description?: string; type: \"boolean\" | \"string\"; default?: boolean | string },\n\t\t): void {\n\t\t\truntime.assertActive();\n\t\t\textension.flags.set(name, { name, extensionPath: extension.path, ...options });\n\t\t\tif (options.default !== undefined && !runtime.flagValues.has(name)) {\n\t\t\t\truntime.flagValues.set(name, options.default);\n\t\t\t}\n\t\t},\n\n\t\tregisterMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void {\n\t\t\truntime.assertActive();\n\t\t\textension.messageRenderers.set(customType, renderer as MessageRenderer);\n\t\t},\n\n\t\t// Flag access - checks extension registered it, reads from runtime\n\t\tgetFlag(name: string): boolean | string | undefined {\n\t\t\truntime.assertActive();\n\t\t\tif (!extension.flags.has(name)) return undefined;\n\t\t\treturn runtime.flagValues.get(name);\n\t\t},\n\n\t\taddModeSearchPath(dirPath: string): void {\n\t\t\truntime.assertActive();\n\t\t\tconst resolved = resolvePath(dirPath, cwd);\n\t\t\tif (!runtime.modeSearchPaths.includes(resolved)) {\n\t\t\t\truntime.modeSearchPaths.push(resolved);\n\t\t\t}\n\t\t},\n\n\t\tgetModeSearchPaths(): string[] {\n\t\t\truntime.assertActive();\n\t\t\treturn [...runtime.modeSearchPaths];\n\t\t},\n\n\t\t// Action methods - delegate to shared runtime\n\t\tsendMessage(message, options): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.sendMessage(message, options);\n\t\t},\n\n\t\tsendUserMessage(content, options): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.sendUserMessage(content, options);\n\t\t},\n\n\t\tappendEntry(customType: string, data?: unknown): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.appendEntry(customType, data);\n\t\t},\n\n\t\tsetSessionName(name: string): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setSessionName(name);\n\t\t},\n\n\t\tgetSessionName(): string | undefined {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getSessionName();\n\t\t},\n\n\t\tsetLabel(entryId: string, label: string | undefined): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setLabel(entryId, label);\n\t\t},\n\n\t\texec(command: string, args: string[], options?: ExecOptions) {\n\t\t\truntime.assertActive();\n\t\t\treturn execCommand(command, args, options?.cwd ?? cwd, options);\n\t\t},\n\n\t\tgetActiveTools(): string[] {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getActiveTools();\n\t\t},\n\n\t\tgetAllTools() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getAllTools();\n\t\t},\n\n\t\tsetActiveTools(toolNames: string[]): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setActiveTools(toolNames);\n\t\t},\n\n\t\tgetCommands() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getCommands();\n\t\t},\n\n\t\tsetModel(model) {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.setModel(model);\n\t\t},\n\n\t\tgetThinkingLevel() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getThinkingLevel();\n\t\t},\n\n\t\tsetThinkingLevel(level) {\n\t\t\truntime.assertActive();\n\t\t\truntime.setThinkingLevel(level);\n\t\t},\n\n\t\tregisterProvider(name: string, config: ProviderConfig) {\n\t\t\truntime.assertActive();\n\t\t\truntime.registerProvider(name, config, extension.path);\n\t\t},\n\n\t\tunregisterProvider(name: string) {\n\t\t\truntime.assertActive();\n\t\t\truntime.unregisterProvider(name, extension.path);\n\t\t},\n\n\t\tevents: eventBus,\n\t} as ExtensionAPI;\n\n\treturn api;\n}\n\nasync function loadExtensionModule(extensionPath: string) {\n\tconst jiti = createJiti(import.meta.url, {\n\t\tmoduleCache: false,\n\t\t// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)\n\t\t// Also disable tryNative so jiti handles ALL imports (not just the entry point)\n\t\t// In Node.js/dev: use aliases to resolve to node_modules paths\n\t\t...(isBunBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : { alias: getAliases() }),\n\t});\n\n\tconst module = await jiti.import(extensionPath, { default: true });\n\tconst factory = module as ExtensionFactory;\n\treturn typeof factory !== \"function\" ? undefined : factory;\n}\n\n/**\n * Create an Extension object with empty collections.\n */\nfunction createExtension(extensionPath: string, resolvedPath: string): Extension {\n\tconst source =\n\t\textensionPath.startsWith(\"<\") && extensionPath.endsWith(\">\")\n\t\t\t? extensionPath.slice(1, -1).split(\":\")[0] || \"temporary\"\n\t\t\t: \"local\";\n\tconst baseDir = extensionPath.startsWith(\"<\") ? undefined : path.dirname(resolvedPath);\n\n\treturn {\n\t\tpath: extensionPath,\n\t\tresolvedPath,\n\t\tsourceInfo: createSyntheticSourceInfo(extensionPath, { source, baseDir }),\n\t\thandlers: new Map(),\n\t\ttools: new Map(),\n\t\tmessageRenderers: new Map(),\n\t\tcommands: new Map(),\n\t\tflags: new Map(),\n\t\tshortcuts: new Map(),\n\t};\n}\n\nasync function loadExtension(\n\textensionPath: string,\n\tcwd: string,\n\teventBus: EventBus,\n\truntime: ExtensionRuntime,\n): Promise<{ extension: Extension | null; error: string | null }> {\n\tconst resolvedPath = resolvePath(extensionPath, cwd);\n\n\ttry {\n\t\tconst factory = await loadExtensionModule(resolvedPath);\n\t\tif (!factory) {\n\t\t\treturn { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };\n\t\t}\n\n\t\tconst extension = createExtension(extensionPath, resolvedPath);\n\t\tconst api = createExtensionAPI(extension, runtime, cwd, eventBus);\n\t\tawait factory(api);\n\n\t\treturn { extension, error: null };\n\t} catch (err) {\n\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\treturn { extension: null, error: `Failed to load extension: ${message}` };\n\t}\n}\n\n/**\n * Create an Extension from an inline factory function.\n */\nexport async function loadExtensionFromFactory(\n\tfactory: ExtensionFactory,\n\tcwd: string,\n\teventBus: EventBus,\n\truntime: ExtensionRuntime,\n\textensionPath = \"<inline>\",\n\tdisplayName?: string,\n): Promise<Extension> {\n\tconst effectivePath = displayName ?? extensionPath;\n\tconst extension = createExtension(effectivePath, extensionPath);\n\tif (displayName) {\n\t\textension.sourceInfo = createSyntheticSourceInfo(displayName, {\n\t\t\tsource: \"inline\",\n\t\t\tscope: \"temporary\",\n\t\t\torigin: \"top-level\",\n\t\t});\n\t}\n\textension.displayName = displayName;\n\textension.internal = factory.internal === true;\n\tconst api = createExtensionAPI(extension, runtime, cwd, eventBus);\n\tawait factory(api);\n\treturn extension;\n}\n\n/**\n * Load extensions from paths.\n */\nexport async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {\n\tconst extensions: Extension[] = [];\n\tconst errors: Array<{ path: string; error: string }> = [];\n\tconst resolvedEventBus = eventBus ?? createEventBus();\n\tconst runtime = createExtensionRuntime();\n\n\tfor (const extPath of paths) {\n\t\tconst { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);\n\n\t\tif (error) {\n\t\t\terrors.push({ path: extPath, error });\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (extension) {\n\t\t\textensions.push(extension);\n\t\t}\n\t}\n\n\treturn {\n\t\textensions,\n\t\terrors,\n\t\truntime,\n\t};\n}\n\ninterface HooCodeManifest {\n\textensions?: string[];\n\tthemes?: string[];\n\tskills?: string[];\n\tprompts?: string[];\n}\n\nfunction readHooCodeManifest(packageJsonPath: string): HooCodeManifest | null {\n\ttry {\n\t\tconst content = fs.readFileSync(packageJsonPath, \"utf-8\");\n\t\tconst pkg = JSON.parse(content);\n\t\tif (pkg.hoocode && typeof pkg.hoocode === \"object\") {\n\t\t\treturn pkg.hoocode as HooCodeManifest;\n\t\t}\n\t\tif (pkg.pi && typeof pkg.pi === \"object\") {\n\t\t\treturn pkg.pi as HooCodeManifest;\n\t\t}\n\t\treturn null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction isExtensionFile(name: string): boolean {\n\treturn name.endsWith(\".ts\") || name.endsWith(\".js\");\n}\n\n/**\n * Resolve extension entry points from a directory.\n *\n * Checks for:\n * 1. package.json with \"pi.extensions\" field -> returns declared paths\n * 2. index.ts or index.js -> returns the index file\n *\n * Returns resolved paths or null if no entry points found.\n */\nfunction resolveExtensionEntries(dir: string): string[] | null {\n\t// Check for package.json with \"pi\" field first\n\tconst packageJsonPath = path.join(dir, \"package.json\");\n\tif (fs.existsSync(packageJsonPath)) {\n\t\tconst manifest = readHooCodeManifest(packageJsonPath);\n\t\tif (manifest?.extensions?.length) {\n\t\t\tconst entries: string[] = [];\n\t\t\tfor (const extPath of manifest.extensions) {\n\t\t\t\tconst resolvedExtPath = path.resolve(dir, extPath);\n\t\t\t\tif (fs.existsSync(resolvedExtPath)) {\n\t\t\t\t\tentries.push(resolvedExtPath);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (entries.length > 0) {\n\t\t\t\treturn entries;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Check for index.ts or index.js\n\tconst indexTs = path.join(dir, \"index.ts\");\n\tconst indexJs = path.join(dir, \"index.js\");\n\tif (fs.existsSync(indexTs)) {\n\t\treturn [indexTs];\n\t}\n\tif (fs.existsSync(indexJs)) {\n\t\treturn [indexJs];\n\t}\n\n\treturn null;\n}\n\n/**\n * Discover extensions in a directory.\n *\n * Discovery rules:\n * 1. Direct files: `extensions/*.ts` or `*.js` → load\n * 2. Subdirectory with index: `extensions/* /index.ts` or `index.js` → load\n * 3. Subdirectory with package.json: `extensions/* /package.json` with \"pi\" field → load what it declares\n *\n * No recursion beyond one level. Complex packages must use package.json manifest.\n */\nfunction discoverExtensionsInDir(dir: string): string[] {\n\tif (!fs.existsSync(dir)) {\n\t\treturn [];\n\t}\n\n\tconst discovered: string[] = [];\n\n\ttry {\n\t\tconst entries = fs.readdirSync(dir, { withFileTypes: true });\n\n\t\tfor (const entry of entries) {\n\t\t\tconst entryPath = path.join(dir, entry.name);\n\n\t\t\t// 1. Direct files: *.ts or *.js\n\t\t\tif ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {\n\t\t\t\tdiscovered.push(entryPath);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// 2 & 3. Subdirectories\n\t\t\tif (entry.isDirectory() || entry.isSymbolicLink()) {\n\t\t\t\tconst entries = resolveExtensionEntries(entryPath);\n\t\t\t\tif (entries) {\n\t\t\t\t\tdiscovered.push(...entries);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} catch {\n\t\treturn [];\n\t}\n\n\treturn discovered;\n}\n\n/**\n * Discover and load extensions from standard locations.\n */\nexport async function discoverAndLoadExtensions(\n\tconfiguredPaths: string[],\n\tcwd: string,\n\tagentDir: string = getAgentDir(),\n\teventBus?: EventBus,\n): Promise<LoadExtensionsResult> {\n\tconst allPaths: string[] = [];\n\tconst seen = new Set<string>();\n\n\tconst addPaths = (paths: string[]) => {\n\t\tfor (const p of paths) {\n\t\t\tconst resolved = path.resolve(p);\n\t\t\tif (!seen.has(resolved)) {\n\t\t\t\tseen.add(resolved);\n\t\t\t\tallPaths.push(p);\n\t\t\t}\n\t\t}\n\t};\n\n\t// 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/\n\tconst localExtDir = path.join(cwd, CONFIG_DIR_NAME, \"extensions\");\n\taddPaths(discoverExtensionsInDir(localExtDir));\n\n\t// 2. Global extensions: agentDir/extensions/\n\tconst globalExtDir = path.join(agentDir, \"extensions\");\n\taddPaths(discoverExtensionsInDir(globalExtDir));\n\n\t// 3. Explicitly configured paths\n\tfor (const p of configuredPaths) {\n\t\tconst resolved = resolvePath(p, cwd);\n\t\tif (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {\n\t\t\t// Check for package.json with pi manifest or index.ts\n\t\t\tconst entries = resolveExtensionEntries(resolved);\n\t\t\tif (entries) {\n\t\t\t\taddPaths(entries);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// No explicit entries - discover individual files in directory\n\t\t\taddPaths(discoverExtensionsInDir(resolved));\n\t\t\tcontinue;\n\t\t}\n\n\t\taddPaths([resolved]);\n\t}\n\n\tconst resolvedEventBus = eventBus ?? createEventBus();\n\tconst result = await loadExtensions(allPaths, cwd, resolvedEventBus);\n\n\t// Plugins: directories under plugins/ with a recognized manifest.\n\tconst pluginDirs = defaultPluginDirs(cwd, agentDir);\n\tconst pluginResult = await loadPlugins(pluginDirs, cwd, resolvedEventBus, result.runtime);\n\tresult.extensions.push(...pluginResult.extensions);\n\tresult.errors.push(...pluginResult.errors);\n\n\treturn result;\n}\n\n/**\n * Standard plugin discovery directories, highest precedence first.\n *\n * `.agents/plugins/` is the cross-vendor, primary home and is listed ahead of the\n * `.hoocode/plugins/` fallback at each scope, so an `.agents`-installed plugin\n * wins over a same-id `.hoocode` one (discoverPlugins is first-wins by id).\n * Project scope beats global. The global `.agents` sibling lives next to the\n * agent dir (`~/.agents` alongside `~/.hoocode`), so it stays parameterized on\n * `agentDir` rather than hardcoding the home directory.\n */\nexport function defaultPluginDirs(cwd: string, agentDir: string = getAgentDir()): string[] {\n\tconst globalAgentsPlugins = path.join(path.dirname(agentDir), \".agents\", \"plugins\");\n\treturn [\n\t\tpath.join(cwd, \".agents\", \"plugins\"),\n\t\tpath.join(cwd, CONFIG_DIR_NAME, \"plugins\"),\n\t\tglobalAgentsPlugins,\n\t\tpath.join(agentDir, \"plugins\"),\n\t];\n}\n\n/**\n * Discover plugins under `pluginDirs` and load each as a synthetic extension into\n * the given runtime/event bus. Clears the extension MCP registry first so reloads\n * rebuild the set cleanly.\n */\nexport async function loadPlugins(\n\tpluginDirs: string[],\n\tcwd: string,\n\teventBus: EventBus,\n\truntime: ExtensionRuntime,\n): Promise<{ extensions: Extension[]; errors: Array<{ path: string; error: string }> }> {\n\tclearExtensionMcpServers();\n\tconst extensions: Extension[] = [];\n\tconst errors: Array<{ path: string; error: string }> = [];\n\n\tfor (const plugin of discoverPlugins(pluginDirs)) {\n\t\ttry {\n\t\t\tconst extension = await loadExtensionFromFactory(\n\t\t\t\tbuildPluginFactory(plugin),\n\t\t\t\tcwd,\n\t\t\t\teventBus,\n\t\t\t\truntime,\n\t\t\t\t`<plugin:${plugin.id}>`,\n\t\t\t\t`plugin:${plugin.id}`,\n\t\t\t);\n\t\t\textensions.push(extension);\n\t\t} catch (err) {\n\t\t\terrors.push({\n\t\t\t\tpath: plugin.manifestPath,\n\t\t\t\terror: `Failed to load plugin \"${plugin.id}\": ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn { extensions, errors };\n}\n"]}
1
+ {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../../src/core/extensions/loader.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAuBH,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAMhE,OAAO,KAAK,EACX,SAAS,EAET,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EAKpB,MAAM,YAAY,CAAC;AA8FpB;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,gBAAgB,CA+CzD;AA4OD;;GAEG;AACH,wBAAsB,wBAAwB,CAC7C,OAAO,EAAE,gBAAgB,EACzB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,gBAAgB,EACzB,aAAa,SAAa,EAC1B,WAAW,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,SAAS,CAAC,CAepB;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAwBrH;AAkHD;;GAEG;AACH,wBAAsB,yBAAyB,CAC9C,eAAe,EAAE,MAAM,EAAE,EACzB,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAsB,EAChC,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,oBAAoB,CAAC,CAkD/B;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAEhF;AASD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,MAAM,EAAE,CAWzF;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAChC,UAAU,EAAE,MAAM,EAAE,EACpB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,gBAAgB,GACvB,OAAO,CAAC;IAAE,UAAU,EAAE,SAAS,EAAE,CAAC;IAAC,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,CA4CtF","sourcesContent":["/**\n * Extension loader - loads TypeScript extension modules using jiti.\n *\n */\n\nimport * as fs from \"node:fs\";\nimport { createRequire } from \"node:module\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport * as _bundledPiAgentCore from \"@kolisachint/hoocode-agent-core\";\nimport * as _bundledPiAi from \"@kolisachint/hoocode-ai\";\nimport * as _bundledPiAiOauth from \"@kolisachint/hoocode-ai/oauth\";\nimport type { KeyId } from \"@kolisachint/hoocode-tui\";\nimport * as _bundledPiTui from \"@kolisachint/hoocode-tui\";\nimport { createJiti } from \"jiti/static\";\n// Static imports of packages that extensions may use.\n// These MUST be static so Bun bundles them into the compiled binary.\n// The virtualModules option then makes them available to extensions.\nimport * as _bundledTypebox from \"typebox\";\nimport * as _bundledTypeboxCompile from \"typebox/compile\";\nimport * as _bundledTypeboxValue from \"typebox/value\";\nimport { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from \"../../config.js\";\n// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,\n// avoiding a circular dependency. Extensions can import from @kolisachint/hoocode-agent.\nimport * as _bundledPiCodingAgent from \"../../index.js\";\nimport { createEventBus, type EventBus } from \"../event-bus.js\";\nimport type { ExecOptions } from \"../exec.js\";\nimport { execCommand } from \"../exec.js\";\nimport { clearExtensionMcpServers } from \"../extension-mcp-servers.js\";\nimport { createSyntheticSourceInfo } from \"../source-info.js\";\nimport { buildPluginFactory, discoverPlugins, pluginExtensionPath, withheldCapabilities } from \"./plugins/index.js\";\nimport type {\n\tExtension,\n\tExtensionAPI,\n\tExtensionFactory,\n\tExtensionRuntime,\n\tLoadExtensionsResult,\n\tMessageRenderer,\n\tProviderConfig,\n\tRegisteredCommand,\n\tToolDefinition,\n} from \"./types.js\";\n\n/** Modules available to extensions via virtualModules (for compiled Bun binary) */\nconst VIRTUAL_MODULES: Record<string, unknown> = {\n\ttypebox: _bundledTypebox,\n\t\"typebox/compile\": _bundledTypeboxCompile,\n\t\"typebox/value\": _bundledTypeboxValue,\n\t\"@sinclair/typebox\": _bundledTypebox,\n\t\"@sinclair/typebox/compile\": _bundledTypeboxCompile,\n\t\"@sinclair/typebox/value\": _bundledTypeboxValue,\n\t\"@kolisachint/hoocode-agent-core\": _bundledPiAgentCore,\n\t\"@kolisachint/hoocode-tui\": _bundledPiTui,\n\t\"@kolisachint/hoocode-ai\": _bundledPiAi,\n\t\"@kolisachint/hoocode-ai/oauth\": _bundledPiAiOauth,\n\t\"@kolisachint/hoocode-agent\": _bundledPiCodingAgent,\n};\n\nconst require = createRequire(import.meta.url);\n\n/**\n * Get aliases for jiti (used in Node.js/development mode).\n * In Bun binary mode, virtualModules is used instead.\n */\nlet _aliases: Record<string, string> | null = null;\n\nfunction getAliases(): Record<string, string> {\n\tif (_aliases) return _aliases;\n\n\tconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\tconst packageIndex = path.resolve(__dirname, \"../..\", \"index.js\");\n\n\tconst typeboxEntry = require.resolve(\"typebox\");\n\tconst typeboxCompileEntry = require.resolve(\"typebox/compile\");\n\tconst typeboxValueEntry = require.resolve(\"typebox/value\");\n\n\tconst packagesRoot = path.resolve(__dirname, \"../../../../\");\n\tconst resolveWorkspaceOrImport = (workspaceRelativePath: string, specifier: string): string => {\n\t\tconst workspacePath = path.join(packagesRoot, workspaceRelativePath);\n\t\tif (fs.existsSync(workspacePath)) {\n\t\t\treturn workspacePath;\n\t\t}\n\t\treturn fileURLToPath(import.meta.resolve(specifier));\n\t};\n\n\tconst hooCodingAgentEntry = packageIndex;\n\tconst hooAgentCoreEntry = resolveWorkspaceOrImport(\"agent/dist/index.js\", \"@kolisachint/hoocode-agent-core\");\n\tconst hooTuiEntry = resolveWorkspaceOrImport(\"tui/dist/index.js\", \"@kolisachint/hoocode-tui\");\n\tconst hooAiEntry = resolveWorkspaceOrImport(\"ai/dist/index.js\", \"@kolisachint/hoocode-ai\");\n\tconst hooAiOauthEntry = resolveWorkspaceOrImport(\"ai/dist/oauth.js\", \"@kolisachint/hoocode-ai/oauth\");\n\n\t_aliases = {\n\t\t\"@kolisachint/hoocode-agent\": hooCodingAgentEntry,\n\t\t\"@kolisachint/hoocode-agent-core\": hooAgentCoreEntry,\n\t\t\"@kolisachint/hoocode-tui\": hooTuiEntry,\n\t\t\"@kolisachint/hoocode-ai\": hooAiEntry,\n\t\t\"@kolisachint/hoocode-ai/oauth\": hooAiOauthEntry,\n\t\ttypebox: typeboxEntry,\n\t\t\"typebox/compile\": typeboxCompileEntry,\n\t\t\"typebox/value\": typeboxValueEntry,\n\t\t\"@sinclair/typebox\": typeboxEntry,\n\t\t\"@sinclair/typebox/compile\": typeboxCompileEntry,\n\t\t\"@sinclair/typebox/value\": typeboxValueEntry,\n\t};\n\n\treturn _aliases;\n}\n\nconst UNICODE_SPACES = /[\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000]/g;\n\nfunction normalizeUnicodeSpaces(str: string): string {\n\treturn str.replace(UNICODE_SPACES, \" \");\n}\n\nfunction expandPath(p: string): string {\n\tconst normalized = normalizeUnicodeSpaces(p);\n\tif (normalized.startsWith(\"~/\")) {\n\t\treturn path.join(os.homedir(), normalized.slice(2));\n\t}\n\tif (normalized.startsWith(\"~\")) {\n\t\treturn path.join(os.homedir(), normalized.slice(1));\n\t}\n\treturn normalized;\n}\n\nfunction resolvePath(extPath: string, cwd: string): string {\n\tconst expanded = expandPath(extPath);\n\tif (path.isAbsolute(expanded)) {\n\t\treturn expanded;\n\t}\n\treturn path.resolve(cwd, expanded);\n}\n\ntype HandlerFn = (...args: unknown[]) => Promise<unknown>;\n\n/**\n * Create a runtime with throwing stubs for action methods.\n * Runner.bindCore() replaces these with real implementations.\n */\nexport function createExtensionRuntime(): ExtensionRuntime {\n\tconst notInitialized = () => {\n\t\tthrow new Error(\"Extension runtime not initialized. Action methods cannot be called during extension loading.\");\n\t};\n\tconst state: { staleMessage?: string } = {};\n\tconst assertActive = () => {\n\t\tif (state.staleMessage) {\n\t\t\tthrow new Error(state.staleMessage);\n\t\t}\n\t};\n\n\tconst runtime: ExtensionRuntime = {\n\t\tsendMessage: notInitialized,\n\t\tsendUserMessage: notInitialized,\n\t\tappendEntry: notInitialized,\n\t\tsetSessionName: notInitialized,\n\t\tgetSessionName: notInitialized,\n\t\tsetLabel: notInitialized,\n\t\tgetActiveTools: notInitialized,\n\t\tgetAllTools: notInitialized,\n\t\tsetActiveTools: notInitialized,\n\t\t// registerTool() is valid during extension load; refresh is only needed post-bind.\n\t\trefreshTools: () => {},\n\t\tgetCommands: notInitialized,\n\t\tsetModel: () => Promise.reject(new Error(\"Extension runtime not initialized\")),\n\t\tgetThinkingLevel: notInitialized,\n\t\tsetThinkingLevel: notInitialized,\n\t\tflagValues: new Map(),\n\t\tpendingProviderRegistrations: [],\n\t\tmodeSearchPaths: [],\n\t\tassertActive,\n\t\tinvalidate: (message) => {\n\t\t\tstate.staleMessage ??=\n\t\t\t\tmessage ??\n\t\t\t\t\"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().\";\n\t\t},\n\t\t// Pre-bind: queue registrations so bindCore() can flush them once the\n\t\t// model registry is available. bindCore() replaces both with direct calls.\n\t\tregisterProvider: (name, config, extensionPath = \"<unknown>\") => {\n\t\t\truntime.pendingProviderRegistrations.push({ name, config, extensionPath });\n\t\t},\n\t\tunregisterProvider: (name) => {\n\t\t\truntime.pendingProviderRegistrations = runtime.pendingProviderRegistrations.filter((r) => r.name !== name);\n\t\t},\n\t};\n\n\treturn runtime;\n}\n\n/**\n * Create the ExtensionAPI for an extension.\n * Registration methods write to the extension object.\n * Action methods delegate to the shared runtime.\n */\nfunction createExtensionAPI(\n\textension: Extension,\n\truntime: ExtensionRuntime,\n\tcwd: string,\n\teventBus: EventBus,\n): ExtensionAPI {\n\tconst api = {\n\t\t// Registration methods - write to extension\n\t\ton(event: string, handler: HandlerFn): void {\n\t\t\truntime.assertActive();\n\t\t\tconst list = extension.handlers.get(event) ?? [];\n\t\t\tlist.push(handler);\n\t\t\textension.handlers.set(event, list);\n\t\t},\n\n\t\tregisterTool(tool: ToolDefinition): void {\n\t\t\truntime.assertActive();\n\t\t\textension.tools.set(tool.name, {\n\t\t\t\tdefinition: tool,\n\t\t\t\tsourceInfo: extension.sourceInfo,\n\t\t\t});\n\t\t\truntime.refreshTools();\n\t\t},\n\n\t\tregisterCommand(name: string, options: Omit<RegisteredCommand, \"name\" | \"sourceInfo\">): void {\n\t\t\truntime.assertActive();\n\t\t\textension.commands.set(name, {\n\t\t\t\tname,\n\t\t\t\tsourceInfo: extension.sourceInfo,\n\t\t\t\t...options,\n\t\t\t});\n\t\t},\n\n\t\tregisterShortcut(\n\t\t\tshortcut: KeyId,\n\t\t\toptions: {\n\t\t\t\tdescription?: string;\n\t\t\t\thandler: (ctx: import(\"./types.js\").ExtensionContext) => Promise<void> | void;\n\t\t\t},\n\t\t): void {\n\t\t\truntime.assertActive();\n\t\t\textension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options });\n\t\t},\n\n\t\tregisterFlag(\n\t\t\tname: string,\n\t\t\toptions: { description?: string; type: \"boolean\" | \"string\"; default?: boolean | string },\n\t\t): void {\n\t\t\truntime.assertActive();\n\t\t\textension.flags.set(name, { name, extensionPath: extension.path, ...options });\n\t\t\tif (options.default !== undefined && !runtime.flagValues.has(name)) {\n\t\t\t\truntime.flagValues.set(name, options.default);\n\t\t\t}\n\t\t},\n\n\t\tregisterMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): void {\n\t\t\truntime.assertActive();\n\t\t\textension.messageRenderers.set(customType, renderer as MessageRenderer);\n\t\t},\n\n\t\t// Flag access - checks extension registered it, reads from runtime\n\t\tgetFlag(name: string): boolean | string | undefined {\n\t\t\truntime.assertActive();\n\t\t\tif (!extension.flags.has(name)) return undefined;\n\t\t\treturn runtime.flagValues.get(name);\n\t\t},\n\n\t\taddModeSearchPath(dirPath: string): void {\n\t\t\truntime.assertActive();\n\t\t\tconst resolved = resolvePath(dirPath, cwd);\n\t\t\tif (!runtime.modeSearchPaths.includes(resolved)) {\n\t\t\t\truntime.modeSearchPaths.push(resolved);\n\t\t\t}\n\t\t},\n\n\t\tgetModeSearchPaths(): string[] {\n\t\t\truntime.assertActive();\n\t\t\treturn [...runtime.modeSearchPaths];\n\t\t},\n\n\t\t// Action methods - delegate to shared runtime\n\t\tsendMessage(message, options): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.sendMessage(message, options);\n\t\t},\n\n\t\tsendUserMessage(content, options): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.sendUserMessage(content, options);\n\t\t},\n\n\t\tappendEntry(customType: string, data?: unknown): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.appendEntry(customType, data);\n\t\t},\n\n\t\tsetSessionName(name: string): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setSessionName(name);\n\t\t},\n\n\t\tgetSessionName(): string | undefined {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getSessionName();\n\t\t},\n\n\t\tsetLabel(entryId: string, label: string | undefined): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setLabel(entryId, label);\n\t\t},\n\n\t\texec(command: string, args: string[], options?: ExecOptions) {\n\t\t\truntime.assertActive();\n\t\t\treturn execCommand(command, args, options?.cwd ?? cwd, options);\n\t\t},\n\n\t\tgetActiveTools(): string[] {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getActiveTools();\n\t\t},\n\n\t\tgetAllTools() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getAllTools();\n\t\t},\n\n\t\tsetActiveTools(toolNames: string[]): void {\n\t\t\truntime.assertActive();\n\t\t\truntime.setActiveTools(toolNames);\n\t\t},\n\n\t\tgetCommands() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getCommands();\n\t\t},\n\n\t\tsetModel(model) {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.setModel(model);\n\t\t},\n\n\t\tgetThinkingLevel() {\n\t\t\truntime.assertActive();\n\t\t\treturn runtime.getThinkingLevel();\n\t\t},\n\n\t\tsetThinkingLevel(level) {\n\t\t\truntime.assertActive();\n\t\t\truntime.setThinkingLevel(level);\n\t\t},\n\n\t\tregisterProvider(name: string, config: ProviderConfig) {\n\t\t\truntime.assertActive();\n\t\t\truntime.registerProvider(name, config, extension.path);\n\t\t},\n\n\t\tunregisterProvider(name: string) {\n\t\t\truntime.assertActive();\n\t\t\truntime.unregisterProvider(name, extension.path);\n\t\t},\n\n\t\tevents: eventBus,\n\t} as ExtensionAPI;\n\n\treturn api;\n}\n\nasync function loadExtensionModule(extensionPath: string) {\n\tconst jiti = createJiti(import.meta.url, {\n\t\tmoduleCache: false,\n\t\t// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)\n\t\t// Also disable tryNative so jiti handles ALL imports (not just the entry point)\n\t\t// In Node.js/dev: use aliases to resolve to node_modules paths\n\t\t...(isBunBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : { alias: getAliases() }),\n\t});\n\n\tconst module = await jiti.import(extensionPath, { default: true });\n\tconst factory = module as ExtensionFactory;\n\treturn typeof factory !== \"function\" ? undefined : factory;\n}\n\n/**\n * Create an Extension object with empty collections.\n */\nfunction createExtension(extensionPath: string, resolvedPath: string): Extension {\n\tconst source =\n\t\textensionPath.startsWith(\"<\") && extensionPath.endsWith(\">\")\n\t\t\t? extensionPath.slice(1, -1).split(\":\")[0] || \"temporary\"\n\t\t\t: \"local\";\n\tconst baseDir = extensionPath.startsWith(\"<\") ? undefined : path.dirname(resolvedPath);\n\n\treturn {\n\t\tpath: extensionPath,\n\t\tresolvedPath,\n\t\tsourceInfo: createSyntheticSourceInfo(extensionPath, { source, baseDir }),\n\t\thandlers: new Map(),\n\t\ttools: new Map(),\n\t\tmessageRenderers: new Map(),\n\t\tcommands: new Map(),\n\t\tflags: new Map(),\n\t\tshortcuts: new Map(),\n\t};\n}\n\nasync function loadExtension(\n\textensionPath: string,\n\tcwd: string,\n\teventBus: EventBus,\n\truntime: ExtensionRuntime,\n): Promise<{ extension: Extension | null; error: string | null }> {\n\tconst resolvedPath = resolvePath(extensionPath, cwd);\n\n\ttry {\n\t\tconst factory = await loadExtensionModule(resolvedPath);\n\t\tif (!factory) {\n\t\t\treturn { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };\n\t\t}\n\n\t\tconst extension = createExtension(extensionPath, resolvedPath);\n\t\tconst api = createExtensionAPI(extension, runtime, cwd, eventBus);\n\t\tawait factory(api);\n\n\t\treturn { extension, error: null };\n\t} catch (err) {\n\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\treturn { extension: null, error: `Failed to load extension: ${message}` };\n\t}\n}\n\n/**\n * Create an Extension from an inline factory function.\n */\nexport async function loadExtensionFromFactory(\n\tfactory: ExtensionFactory,\n\tcwd: string,\n\teventBus: EventBus,\n\truntime: ExtensionRuntime,\n\textensionPath = \"<inline>\",\n\tdisplayName?: string,\n): Promise<Extension> {\n\tconst effectivePath = displayName ?? extensionPath;\n\tconst extension = createExtension(effectivePath, extensionPath);\n\tif (displayName) {\n\t\textension.sourceInfo = createSyntheticSourceInfo(displayName, {\n\t\t\tsource: \"inline\",\n\t\t\tscope: \"temporary\",\n\t\t\torigin: \"top-level\",\n\t\t});\n\t}\n\textension.displayName = displayName;\n\textension.internal = factory.internal === true;\n\tconst api = createExtensionAPI(extension, runtime, cwd, eventBus);\n\tawait factory(api);\n\treturn extension;\n}\n\n/**\n * Load extensions from paths.\n */\nexport async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {\n\tconst extensions: Extension[] = [];\n\tconst errors: Array<{ path: string; error: string }> = [];\n\tconst resolvedEventBus = eventBus ?? createEventBus();\n\tconst runtime = createExtensionRuntime();\n\n\tfor (const extPath of paths) {\n\t\tconst { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);\n\n\t\tif (error) {\n\t\t\terrors.push({ path: extPath, error });\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (extension) {\n\t\t\textensions.push(extension);\n\t\t}\n\t}\n\n\treturn {\n\t\textensions,\n\t\terrors,\n\t\truntime,\n\t};\n}\n\ninterface HooCodeManifest {\n\textensions?: string[];\n\tthemes?: string[];\n\tskills?: string[];\n\tprompts?: string[];\n}\n\nfunction readHooCodeManifest(packageJsonPath: string): HooCodeManifest | null {\n\ttry {\n\t\tconst content = fs.readFileSync(packageJsonPath, \"utf-8\");\n\t\tconst pkg = JSON.parse(content);\n\t\tif (pkg.hoocode && typeof pkg.hoocode === \"object\") {\n\t\t\treturn pkg.hoocode as HooCodeManifest;\n\t\t}\n\t\tif (pkg.pi && typeof pkg.pi === \"object\") {\n\t\t\treturn pkg.pi as HooCodeManifest;\n\t\t}\n\t\treturn null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction isExtensionFile(name: string): boolean {\n\treturn name.endsWith(\".ts\") || name.endsWith(\".js\");\n}\n\n/**\n * Resolve extension entry points from a directory.\n *\n * Checks for:\n * 1. package.json with \"pi.extensions\" field -> returns declared paths\n * 2. index.ts or index.js -> returns the index file\n *\n * Returns resolved paths or null if no entry points found.\n */\nfunction resolveExtensionEntries(dir: string): string[] | null {\n\t// Check for package.json with \"pi\" field first\n\tconst packageJsonPath = path.join(dir, \"package.json\");\n\tif (fs.existsSync(packageJsonPath)) {\n\t\tconst manifest = readHooCodeManifest(packageJsonPath);\n\t\tif (manifest?.extensions?.length) {\n\t\t\tconst entries: string[] = [];\n\t\t\tfor (const extPath of manifest.extensions) {\n\t\t\t\tconst resolvedExtPath = path.resolve(dir, extPath);\n\t\t\t\tif (fs.existsSync(resolvedExtPath)) {\n\t\t\t\t\tentries.push(resolvedExtPath);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (entries.length > 0) {\n\t\t\t\treturn entries;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Check for index.ts or index.js\n\tconst indexTs = path.join(dir, \"index.ts\");\n\tconst indexJs = path.join(dir, \"index.js\");\n\tif (fs.existsSync(indexTs)) {\n\t\treturn [indexTs];\n\t}\n\tif (fs.existsSync(indexJs)) {\n\t\treturn [indexJs];\n\t}\n\n\treturn null;\n}\n\n/**\n * Discover extensions in a directory.\n *\n * Discovery rules:\n * 1. Direct files: `extensions/*.ts` or `*.js` → load\n * 2. Subdirectory with index: `extensions/* /index.ts` or `index.js` → load\n * 3. Subdirectory with package.json: `extensions/* /package.json` with \"pi\" field → load what it declares\n *\n * No recursion beyond one level. Complex packages must use package.json manifest.\n */\nfunction discoverExtensionsInDir(dir: string): string[] {\n\tif (!fs.existsSync(dir)) {\n\t\treturn [];\n\t}\n\n\tconst discovered: string[] = [];\n\n\ttry {\n\t\tconst entries = fs.readdirSync(dir, { withFileTypes: true });\n\n\t\tfor (const entry of entries) {\n\t\t\tconst entryPath = path.join(dir, entry.name);\n\n\t\t\t// 1. Direct files: *.ts or *.js\n\t\t\tif ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {\n\t\t\t\tdiscovered.push(entryPath);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// 2 & 3. Subdirectories\n\t\t\tif (entry.isDirectory() || entry.isSymbolicLink()) {\n\t\t\t\tconst entries = resolveExtensionEntries(entryPath);\n\t\t\t\tif (entries) {\n\t\t\t\t\tdiscovered.push(...entries);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} catch {\n\t\treturn [];\n\t}\n\n\treturn discovered;\n}\n\n/**\n * Discover and load extensions from standard locations.\n */\nexport async function discoverAndLoadExtensions(\n\tconfiguredPaths: string[],\n\tcwd: string,\n\tagentDir: string = getAgentDir(),\n\teventBus?: EventBus,\n): Promise<LoadExtensionsResult> {\n\tconst allPaths: string[] = [];\n\tconst seen = new Set<string>();\n\n\tconst addPaths = (paths: string[]) => {\n\t\tfor (const p of paths) {\n\t\t\tconst resolved = path.resolve(p);\n\t\t\tif (!seen.has(resolved)) {\n\t\t\t\tseen.add(resolved);\n\t\t\t\tallPaths.push(p);\n\t\t\t}\n\t\t}\n\t};\n\n\t// 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/\n\tconst localExtDir = path.join(cwd, CONFIG_DIR_NAME, \"extensions\");\n\taddPaths(discoverExtensionsInDir(localExtDir));\n\n\t// 2. Global extensions: agentDir/extensions/\n\tconst globalExtDir = path.join(agentDir, \"extensions\");\n\taddPaths(discoverExtensionsInDir(globalExtDir));\n\n\t// 3. Explicitly configured paths\n\tfor (const p of configuredPaths) {\n\t\tconst resolved = resolvePath(p, cwd);\n\t\tif (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {\n\t\t\t// Check for package.json with pi manifest or index.ts\n\t\t\tconst entries = resolveExtensionEntries(resolved);\n\t\t\tif (entries) {\n\t\t\t\taddPaths(entries);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// No explicit entries - discover individual files in directory\n\t\t\taddPaths(discoverExtensionsInDir(resolved));\n\t\t\tcontinue;\n\t\t}\n\n\t\taddPaths([resolved]);\n\t}\n\n\tconst resolvedEventBus = eventBus ?? createEventBus();\n\tconst result = await loadExtensions(allPaths, cwd, resolvedEventBus);\n\n\t// Plugins: directories under plugins/ with a recognized manifest.\n\tconst pluginDirs = defaultPluginDirs(cwd, agentDir);\n\tconst pluginResult = await loadPlugins(pluginDirs, cwd, resolvedEventBus, result.runtime);\n\tresult.extensions.push(...pluginResult.extensions);\n\tresult.errors.push(...pluginResult.errors);\n\n\treturn result;\n}\n\n/**\n * Whether a discovered plugin came with the repository rather than from the user.\n *\n * Scoped to `<cwd>/.claude/skills` — the vendor convention for repo-committed,\n * collaborator-shared plugins, which Claude Code puts behind a workspace trust\n * dialog. The other project paths (`.agents/plugins`, `.hoocode/plugins`) are\n * hoocode's own former install homes, holding plugins the user installed\n * deliberately, so treating those as untrusted would break existing setups.\n */\nexport function isProjectSuppliedPlugin(pluginRoot: string, cwd: string): boolean {\n\treturn isUnderDir(pluginRoot, path.join(cwd, \".claude\", \"skills\"));\n}\n\n/** True when `target` is `root` or sits inside it. */\nfunction isUnderDir(target: string, root: string): boolean {\n\tconst normalized = path.resolve(root);\n\tif (path.resolve(target) === normalized) return true;\n\treturn path.resolve(target).startsWith(normalized.endsWith(path.sep) ? normalized : `${normalized}${path.sep}`);\n}\n\n/**\n * Standard plugin discovery directories, highest precedence first.\n *\n * `.agents/plugins/` is the cross-vendor, primary home and is listed ahead of the\n * `.hoocode/plugins/` fallback at each scope, so an `.agents`-installed plugin\n * wins over a same-id `.hoocode` one (discoverPlugins is first-wins by id).\n * Project scope beats global. The global surfaces live next to the agent dir\n * (`~/.agents`, `~/.claude` alongside `~/.hoocode`), so they stay parameterized\n * on `agentDir` rather than hardcoding the home directory.\n *\n * Two of these are *production homes* for plugins hoocode authored, and two are\n * skills directories:\n *\n * - `<cwd>/.agents/plugins` is the legacy project-local install home. Nothing\n * writes there any more; it is read so plugins installed by older versions\n * keep working.\n * - `.claude/skills` (project and personal) implements Claude Code's\n * skills-directory plugins: a folder there carrying `.claude-plugin/plugin.json`\n * is a plugin, and a folder with only a `SKILL.md` stays a plain skill —\n * `parsePluginDir` returns null for the latter, which is exactly the vendor's\n * own rule, so no special-casing is needed here.\n *\n * See docs/plugin-system-architecture.md §5.3 and §5.7.\n */\nexport function defaultPluginDirs(cwd: string, agentDir: string = getAgentDir()): string[] {\n\tconst home = path.dirname(agentDir);\n\treturn [\n\t\tpath.join(cwd, \".agents\", \"plugins\"),\n\t\tpath.join(cwd, CONFIG_DIR_NAME, \"plugins\"),\n\t\tpath.join(cwd, \".claude\", \"skills\"),\n\t\tpath.join(home, \".agents\", \"plugins\"),\n\t\tpath.join(home, \".agents\", \"publish\", \"github\"),\n\t\tpath.join(home, \".claude\", \"skills\"),\n\t\tpath.join(agentDir, \"plugins\"),\n\t];\n}\n\n/**\n * Discover plugins under `pluginDirs` and load each as a synthetic extension into\n * the given runtime/event bus. Clears the extension MCP registry first so reloads\n * rebuild the set cleanly.\n */\nexport async function loadPlugins(\n\tpluginDirs: string[],\n\tcwd: string,\n\teventBus: EventBus,\n\truntime: ExtensionRuntime,\n): Promise<{ extensions: Extension[]; errors: Array<{ path: string; error: string }> }> {\n\tclearExtensionMcpServers();\n\tconst extensions: Extension[] = [];\n\tconst errors: Array<{ path: string; error: string }> = [];\n\n\tfor (const plugin of discoverPlugins(pluginDirs)) {\n\t\ttry {\n\t\t\t// Withhold the executable half of a plugin that came with the repository\n\t\t\t// rather than from the user. Scoped to `<cwd>/.claude/skills` — the\n\t\t\t// vendor convention for repo-committed, collaborator-shared plugins,\n\t\t\t// which Claude Code itself puts behind a workspace trust dialog. The\n\t\t\t// other project paths (`.agents/plugins`, `.hoocode/plugins`) are\n\t\t\t// hoocode's own former install homes, holding plugins the user installed\n\t\t\t// deliberately, so gating those would break existing setups.\n\t\t\t// See PluginFactoryOptions.passiveOnly.\n\t\t\tconst passiveOnly = isProjectSuppliedPlugin(plugin.root, cwd);\n\t\t\tconst withheld = passiveOnly ? withheldCapabilities(plugin) : [];\n\t\t\tconst extension = await loadExtensionFromFactory(\n\t\t\t\tbuildPluginFactory(plugin, { passiveOnly }),\n\t\t\t\tcwd,\n\t\t\t\teventBus,\n\t\t\t\truntime,\n\t\t\t\tpluginExtensionPath(plugin.id),\n\t\t\t\t`plugin:${plugin.id}`,\n\t\t\t);\n\t\t\textensions.push(extension);\n\t\t\tif (withheld.length > 0) {\n\t\t\t\terrors.push({\n\t\t\t\t\tpath: plugin.manifestPath,\n\t\t\t\t\terror:\n\t\t\t\t\t\t`Project-scoped plugin \"${plugin.id}\": ${withheld.join(\" and \")} not loaded. ` +\n\t\t\t\t\t\t\"Executable capabilities from a project directory require a trust gate hoocode does not have; \" +\n\t\t\t\t\t\t\"install the plugin for your user instead if you trust it.\",\n\t\t\t\t});\n\t\t\t}\n\t\t} catch (err) {\n\t\t\terrors.push({\n\t\t\t\tpath: plugin.manifestPath,\n\t\t\t\terror: `Failed to load plugin \"${plugin.id}\": ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn { extensions, errors };\n}\n"]}
@@ -26,7 +26,7 @@ import { createEventBus } from "../event-bus.js";
26
26
  import { execCommand } from "../exec.js";
27
27
  import { clearExtensionMcpServers } from "../extension-mcp-servers.js";
28
28
  import { createSyntheticSourceInfo } from "../source-info.js";
29
- import { buildPluginFactory, discoverPlugins } from "./plugins/index.js";
29
+ import { buildPluginFactory, discoverPlugins, pluginExtensionPath, withheldCapabilities } from "./plugins/index.js";
30
30
  /** Modules available to extensions via virtualModules (for compiled Bun binary) */
31
31
  const VIRTUAL_MODULES = {
32
32
  typebox: _bundledTypebox,
@@ -519,22 +519,58 @@ export async function discoverAndLoadExtensions(configuredPaths, cwd, agentDir =
519
519
  result.errors.push(...pluginResult.errors);
520
520
  return result;
521
521
  }
522
+ /**
523
+ * Whether a discovered plugin came with the repository rather than from the user.
524
+ *
525
+ * Scoped to `<cwd>/.claude/skills` — the vendor convention for repo-committed,
526
+ * collaborator-shared plugins, which Claude Code puts behind a workspace trust
527
+ * dialog. The other project paths (`.agents/plugins`, `.hoocode/plugins`) are
528
+ * hoocode's own former install homes, holding plugins the user installed
529
+ * deliberately, so treating those as untrusted would break existing setups.
530
+ */
531
+ export function isProjectSuppliedPlugin(pluginRoot, cwd) {
532
+ return isUnderDir(pluginRoot, path.join(cwd, ".claude", "skills"));
533
+ }
534
+ /** True when `target` is `root` or sits inside it. */
535
+ function isUnderDir(target, root) {
536
+ const normalized = path.resolve(root);
537
+ if (path.resolve(target) === normalized)
538
+ return true;
539
+ return path.resolve(target).startsWith(normalized.endsWith(path.sep) ? normalized : `${normalized}${path.sep}`);
540
+ }
522
541
  /**
523
542
  * Standard plugin discovery directories, highest precedence first.
524
543
  *
525
544
  * `.agents/plugins/` is the cross-vendor, primary home and is listed ahead of the
526
545
  * `.hoocode/plugins/` fallback at each scope, so an `.agents`-installed plugin
527
546
  * wins over a same-id `.hoocode` one (discoverPlugins is first-wins by id).
528
- * Project scope beats global. The global `.agents` sibling lives next to the
529
- * agent dir (`~/.agents` alongside `~/.hoocode`), so it stays parameterized on
530
- * `agentDir` rather than hardcoding the home directory.
547
+ * Project scope beats global. The global surfaces live next to the agent dir
548
+ * (`~/.agents`, `~/.claude` alongside `~/.hoocode`), so they stay parameterized
549
+ * on `agentDir` rather than hardcoding the home directory.
550
+ *
551
+ * Two of these are *production homes* for plugins hoocode authored, and two are
552
+ * skills directories:
553
+ *
554
+ * - `<cwd>/.agents/plugins` is the legacy project-local install home. Nothing
555
+ * writes there any more; it is read so plugins installed by older versions
556
+ * keep working.
557
+ * - `.claude/skills` (project and personal) implements Claude Code's
558
+ * skills-directory plugins: a folder there carrying `.claude-plugin/plugin.json`
559
+ * is a plugin, and a folder with only a `SKILL.md` stays a plain skill —
560
+ * `parsePluginDir` returns null for the latter, which is exactly the vendor's
561
+ * own rule, so no special-casing is needed here.
562
+ *
563
+ * See docs/plugin-system-architecture.md §5.3 and §5.7.
531
564
  */
532
565
  export function defaultPluginDirs(cwd, agentDir = getAgentDir()) {
533
- const globalAgentsPlugins = path.join(path.dirname(agentDir), ".agents", "plugins");
566
+ const home = path.dirname(agentDir);
534
567
  return [
535
568
  path.join(cwd, ".agents", "plugins"),
536
569
  path.join(cwd, CONFIG_DIR_NAME, "plugins"),
537
- globalAgentsPlugins,
570
+ path.join(cwd, ".claude", "skills"),
571
+ path.join(home, ".agents", "plugins"),
572
+ path.join(home, ".agents", "publish", "github"),
573
+ path.join(home, ".claude", "skills"),
538
574
  path.join(agentDir, "plugins"),
539
575
  ];
540
576
  }
@@ -549,8 +585,26 @@ export async function loadPlugins(pluginDirs, cwd, eventBus, runtime) {
549
585
  const errors = [];
550
586
  for (const plugin of discoverPlugins(pluginDirs)) {
551
587
  try {
552
- const extension = await loadExtensionFromFactory(buildPluginFactory(plugin), cwd, eventBus, runtime, `<plugin:${plugin.id}>`, `plugin:${plugin.id}`);
588
+ // Withhold the executable half of a plugin that came with the repository
589
+ // rather than from the user. Scoped to `<cwd>/.claude/skills` — the
590
+ // vendor convention for repo-committed, collaborator-shared plugins,
591
+ // which Claude Code itself puts behind a workspace trust dialog. The
592
+ // other project paths (`.agents/plugins`, `.hoocode/plugins`) are
593
+ // hoocode's own former install homes, holding plugins the user installed
594
+ // deliberately, so gating those would break existing setups.
595
+ // See PluginFactoryOptions.passiveOnly.
596
+ const passiveOnly = isProjectSuppliedPlugin(plugin.root, cwd);
597
+ const withheld = passiveOnly ? withheldCapabilities(plugin) : [];
598
+ const extension = await loadExtensionFromFactory(buildPluginFactory(plugin, { passiveOnly }), cwd, eventBus, runtime, pluginExtensionPath(plugin.id), `plugin:${plugin.id}`);
553
599
  extensions.push(extension);
600
+ if (withheld.length > 0) {
601
+ errors.push({
602
+ path: plugin.manifestPath,
603
+ error: `Project-scoped plugin "${plugin.id}": ${withheld.join(" and ")} not loaded. ` +
604
+ "Executable capabilities from a project directory require a trust gate hoocode does not have; " +
605
+ "install the plugin for your user instead if you trust it.",
606
+ });
607
+ }
554
608
  }
555
609
  catch (err) {
556
610
  errors.push({