@kolisachint/hoocode-agent 0.5.22 → 0.5.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -0
- package/dist/config.js.map +1 -1
- package/dist/core/canvas/discovery.d.ts +60 -0
- package/dist/core/canvas/discovery.d.ts.map +1 -0
- package/dist/core/canvas/discovery.js +83 -0
- package/dist/core/canvas/discovery.js.map +1 -0
- package/dist/core/canvas/launch.d.ts +90 -0
- package/dist/core/canvas/launch.d.ts.map +1 -0
- package/dist/core/canvas/launch.js +176 -0
- package/dist/core/canvas/launch.js.map +1 -0
- package/dist/core/canvas/protocol.d.ts +229 -0
- package/dist/core/canvas/protocol.d.ts.map +1 -0
- package/dist/core/canvas/protocol.js +129 -0
- package/dist/core/canvas/protocol.js.map +1 -0
- package/dist/core/canvas/registry.d.ts +139 -0
- package/dist/core/canvas/registry.d.ts.map +1 -0
- package/dist/core/canvas/registry.js +275 -0
- package/dist/core/canvas/registry.js.map +1 -0
- package/dist/core/canvas/resolver.d.ts +26 -0
- package/dist/core/canvas/resolver.d.ts.map +1 -0
- package/dist/core/canvas/resolver.js +57 -0
- package/dist/core/canvas/resolver.js.map +1 -0
- package/dist/core/canvas/runner.d.ts +101 -0
- package/dist/core/canvas/runner.d.ts.map +1 -0
- package/dist/core/canvas/runner.js +196 -0
- package/dist/core/canvas/runner.js.map +1 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.d.ts +23 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.d.ts.map +1 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.js +36 -0
- package/dist/core/canvas/sdk-shim/dispatch.test-helpers.js.map +1 -0
- package/dist/core/canvas/sdk-shim/index.d.ts +113 -0
- package/dist/core/canvas/sdk-shim/index.d.ts.map +1 -0
- package/dist/core/canvas/sdk-shim/index.js +184 -0
- package/dist/core/canvas/sdk-shim/index.js.map +1 -0
- package/dist/core/canvas/session.d.ts +112 -0
- package/dist/core/canvas/session.d.ts.map +1 -0
- package/dist/core/canvas/session.js +197 -0
- package/dist/core/canvas/session.js.map +1 -0
- package/dist/core/canvas/trust.d.ts +71 -0
- package/dist/core/canvas/trust.d.ts.map +1 -0
- package/dist/core/canvas/trust.js +88 -0
- package/dist/core/canvas/trust.js.map +1 -0
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +7 -10
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/plugins/trust.d.ts +21 -0
- package/dist/core/extensions/plugins/trust.d.ts.map +1 -1
- package/dist/core/extensions/plugins/trust.js +26 -0
- package/dist/core/extensions/plugins/trust.js.map +1 -1
- package/dist/core/tools/canvas.d.ts +55 -0
- package/dist/core/tools/canvas.d.ts.map +1 -0
- package/dist/core/tools/canvas.js +159 -0
- package/dist/core/tools/canvas.js.map +1 -0
- package/dist/extensions/core/canvas.d.ts +20 -0
- package/dist/extensions/core/canvas.d.ts.map +1 -0
- package/dist/extensions/core/canvas.js +192 -0
- package/dist/extensions/core/canvas.js.map +1 -0
- package/dist/extensions/core/hoo-core.d.ts +1 -0
- package/dist/extensions/core/hoo-core.d.ts.map +1 -1
- package/dist/extensions/core/hoo-core.js +3 -0
- package/dist/extensions/core/hoo-core.js.map +1 -1
- package/dist/utils/paths.d.ts +9 -0
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +16 -0
- package/dist/utils/paths.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +5 -4
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped canvas facade: everything the TUI needs, with no TUI in it.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/canvas-extensions-design.md` §11. The pieces underneath — discovery,
|
|
5
|
+
* the trust gate, availability, the registry — are each small and separately tested.
|
|
6
|
+
* This is what stitches them into the four questions a user surface actually asks:
|
|
7
|
+
* what is there, can it run, open this one, close that one.
|
|
8
|
+
*
|
|
9
|
+
* It holds no TUI types on purpose. `extensions/core/canvas.ts` renders and supplies
|
|
10
|
+
* an `AbortSignal` from a cancellable loader; everything decided here stays testable
|
|
11
|
+
* without a terminal.
|
|
12
|
+
*
|
|
13
|
+
* Availability is resolved once and cached, because resolving can spawn
|
|
14
|
+
* `node --version` (§11.1) and the answer cannot change within a session.
|
|
15
|
+
*/
|
|
16
|
+
import { getAgentDir } from "../../config.js";
|
|
17
|
+
import { canvasSearchRoots, discoverCanvasExtensions } from "./discovery.js";
|
|
18
|
+
import { resolveCanvasRuntime } from "./launch.js";
|
|
19
|
+
import { CanvasRegistry } from "./registry.js";
|
|
20
|
+
import { gateCanvasExtensions } from "./trust.js";
|
|
21
|
+
/**
|
|
22
|
+
* Parse `extension` or `extension:canvas`.
|
|
23
|
+
*
|
|
24
|
+
* Extension ids are directory names and canvas ids are provider-local, so a single
|
|
25
|
+
* colon is unambiguous and needs no quoting.
|
|
26
|
+
*/
|
|
27
|
+
export function parseCanvasRef(input) {
|
|
28
|
+
const trimmed = input.trim();
|
|
29
|
+
if (trimmed.length === 0)
|
|
30
|
+
return undefined;
|
|
31
|
+
const colon = trimmed.indexOf(":");
|
|
32
|
+
if (colon === -1)
|
|
33
|
+
return { extensionId: trimmed };
|
|
34
|
+
const extensionId = trimmed.slice(0, colon).trim();
|
|
35
|
+
const canvasId = trimmed.slice(colon + 1).trim();
|
|
36
|
+
if (extensionId.length === 0 || canvasId.length === 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
return { extensionId, canvasId };
|
|
39
|
+
}
|
|
40
|
+
export class CanvasSession {
|
|
41
|
+
options;
|
|
42
|
+
roots;
|
|
43
|
+
/** Cached because resolving can spawn `node --version` and cannot change mid-session. */
|
|
44
|
+
availabilityPromise;
|
|
45
|
+
/** Created on first successful open, not at construction: listing must not fork. */
|
|
46
|
+
registry;
|
|
47
|
+
constructor(options) {
|
|
48
|
+
this.options = options;
|
|
49
|
+
this.roots = options.roots ?? canvasSearchRoots(options.cwd, options.homeDir);
|
|
50
|
+
}
|
|
51
|
+
/** Discovered extensions, partitioned by the trust gate. Read-only and always safe. */
|
|
52
|
+
discover() {
|
|
53
|
+
const gated = gateCanvasExtensions(discoverCanvasExtensions(this.roots), this.options.cwd, this.options.agentDir ?? getAgentDir());
|
|
54
|
+
return { runnable: gated.runnable, withheld: gated.withheld.map((entry) => entry.extension) };
|
|
55
|
+
}
|
|
56
|
+
/** Whether canvases can run here. Resolved once per session and cached. */
|
|
57
|
+
async availability() {
|
|
58
|
+
this.availabilityPromise ??= (this.options.resolveRuntime ?? resolveCanvasRuntime)();
|
|
59
|
+
return this.availabilityPromise;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* What is installed, what is open, and what is being withheld.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately does not fork anything: listing must stay free and safe, so a
|
|
65
|
+
* `canvasId` is only known for extensions already running. That is the visible
|
|
66
|
+
* consequence of a canvas having no passive half (§5.1) — even its name comes from
|
|
67
|
+
* running its code.
|
|
68
|
+
*/
|
|
69
|
+
async list() {
|
|
70
|
+
const availability = await this.availability();
|
|
71
|
+
const { runnable, withheld } = this.discover();
|
|
72
|
+
const open = this.registryOrUndefined()?.listInstances() ?? [];
|
|
73
|
+
const listings = [];
|
|
74
|
+
for (const extension of runnable) {
|
|
75
|
+
const instances = open.filter((instance) => instance.extensionId === extension.id);
|
|
76
|
+
if (instances.length === 0) {
|
|
77
|
+
listings.push({
|
|
78
|
+
extensionId: extension.id,
|
|
79
|
+
canvasId: undefined,
|
|
80
|
+
displayName: undefined,
|
|
81
|
+
scope: extension.scope,
|
|
82
|
+
withheld: undefined,
|
|
83
|
+
open: [],
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
for (const canvasId of new Set(instances.map((instance) => instance.canvasId))) {
|
|
88
|
+
const forCanvas = instances.filter((instance) => instance.canvasId === canvasId);
|
|
89
|
+
listings.push({
|
|
90
|
+
extensionId: extension.id,
|
|
91
|
+
canvasId,
|
|
92
|
+
displayName: forCanvas[0]?.title,
|
|
93
|
+
scope: extension.scope,
|
|
94
|
+
withheld: undefined,
|
|
95
|
+
open: forCanvas,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const extension of withheld) {
|
|
100
|
+
listings.push({
|
|
101
|
+
extensionId: extension.id,
|
|
102
|
+
canvasId: undefined,
|
|
103
|
+
displayName: undefined,
|
|
104
|
+
scope: extension.scope,
|
|
105
|
+
withheld: "untrusted-workspace",
|
|
106
|
+
open: [],
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return { availability, listings, withheldCount: withheld.length };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Open a canvas.
|
|
113
|
+
*
|
|
114
|
+
* `options.signal` comes from the caller's cancellable loader, so a person's Esc
|
|
115
|
+
* reaches the registry's abandon path (§11.6) rather than merely hiding a spinner.
|
|
116
|
+
*/
|
|
117
|
+
async open(ref, options) {
|
|
118
|
+
const availability = await this.availability();
|
|
119
|
+
if (!availability.available)
|
|
120
|
+
throw new Error(availability.reason);
|
|
121
|
+
const { runnable, withheld } = this.discover();
|
|
122
|
+
const extension = runnable.find((candidate) => candidate.id === ref.extensionId);
|
|
123
|
+
if (!extension) {
|
|
124
|
+
if (withheld.some((candidate) => candidate.id === ref.extensionId)) {
|
|
125
|
+
throw new Error(`Canvas extension "${ref.extensionId}" came with this repository, which is not a trusted workspace. ` +
|
|
126
|
+
"Run /plugin trust to allow this directory to run code it ships.");
|
|
127
|
+
}
|
|
128
|
+
const known = runnable.map((candidate) => candidate.id).join(", ") || "none";
|
|
129
|
+
throw new Error(`No canvas extension "${ref.extensionId}" (found: ${known}).`);
|
|
130
|
+
}
|
|
131
|
+
const registry = this.ensureRegistry(availability);
|
|
132
|
+
const canvasId = ref.canvasId ?? (await this.soleCanvasId(registry, extension));
|
|
133
|
+
return registry.open(extension, canvasId, undefined, options);
|
|
134
|
+
}
|
|
135
|
+
/** Close one open instance. Unknown ids are a no-op, so closing twice is harmless. */
|
|
136
|
+
async close(instanceId) {
|
|
137
|
+
const registry = this.registryOrUndefined();
|
|
138
|
+
const instance = registry?.listInstances().find((open) => open.instanceId === instanceId);
|
|
139
|
+
if (!registry || !instance)
|
|
140
|
+
return undefined;
|
|
141
|
+
await registry.close(instance);
|
|
142
|
+
return instance;
|
|
143
|
+
}
|
|
144
|
+
/** Every open instance. */
|
|
145
|
+
instances() {
|
|
146
|
+
return this.registryOrUndefined()?.listInstances() ?? [];
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The live registry, or undefined if nothing has been opened yet.
|
|
150
|
+
*
|
|
151
|
+
* Exposed so the host can hand it to the canvas tools, which read
|
|
152
|
+
* `listInstances()` and `activeActions()` from it.
|
|
153
|
+
*/
|
|
154
|
+
registryOrUndefined() {
|
|
155
|
+
return this.registry;
|
|
156
|
+
}
|
|
157
|
+
/** Advisory cleanup, driven by whoever owns the session clock. */
|
|
158
|
+
async reapIdle() {
|
|
159
|
+
return (await this.registryOrUndefined()?.reapIdle()) ?? [];
|
|
160
|
+
}
|
|
161
|
+
/** Close everything and stop every child. Safe to call twice. */
|
|
162
|
+
async dispose() {
|
|
163
|
+
await this.registry?.shutdown();
|
|
164
|
+
this.registry = undefined;
|
|
165
|
+
}
|
|
166
|
+
ensureRegistry(availability) {
|
|
167
|
+
if (!this.registry) {
|
|
168
|
+
this.registry = new CanvasRegistry({
|
|
169
|
+
runtime: availability.runtime,
|
|
170
|
+
cwd: this.options.cwd,
|
|
171
|
+
agentDir: this.options.agentDir,
|
|
172
|
+
onLog: this.options.onLog,
|
|
173
|
+
onStray: this.options.onStray,
|
|
174
|
+
onStderr: this.options.onStderr,
|
|
175
|
+
onDiagnostic: this.options.onDiagnostic,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return this.registry;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Pick the canvas when the caller named only an extension.
|
|
182
|
+
*
|
|
183
|
+
* Forking to read the declarations is unavoidable: they arrive in the child's
|
|
184
|
+
* `ready` message. A multi-canvas extension must be named explicitly rather than
|
|
185
|
+
* guessed at.
|
|
186
|
+
*/
|
|
187
|
+
async soleCanvasId(registry, extension) {
|
|
188
|
+
const declarations = await registry.declarations(extension);
|
|
189
|
+
if (declarations.length === 1)
|
|
190
|
+
return declarations[0]?.id;
|
|
191
|
+
if (declarations.length === 0)
|
|
192
|
+
throw new Error(`Canvas extension "${extension.id}" declares no canvases.`);
|
|
193
|
+
const ids = declarations.map((declaration) => `${extension.id}:${declaration.id}`).join(", ");
|
|
194
|
+
throw new Error(`Canvas extension "${extension.id}" declares several canvases; name one of: ${ids}.`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../../src/core/canvas/session.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session-scoped canvas facade: everything the TUI needs, with no TUI in it.\n *\n * Design: `docs/canvas-extensions-design.md` §11. The pieces underneath — discovery,\n * the trust gate, availability, the registry — are each small and separately tested.\n * This is what stitches them into the four questions a user surface actually asks:\n * what is there, can it run, open this one, close that one.\n *\n * It holds no TUI types on purpose. `extensions/core/canvas.ts` renders and supplies\n * an `AbortSignal` from a cancellable loader; everything decided here stays testable\n * without a terminal.\n *\n * Availability is resolved once and cached, because resolving can spawn\n * `node --version` (§11.1) and the answer cannot change within a session.\n */\n\nimport { getAgentDir } from \"../../config.js\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\nimport { type CanvasSearchRoot, canvasSearchRoots, discoverCanvasExtensions } from \"./discovery.js\";\nimport { type CanvasAvailability, resolveCanvasRuntime } from \"./launch.js\";\nimport { type CanvasInstance, CanvasRegistry, type CanvasRegistryEvents } from \"./registry.js\";\nimport type { CanvasCallOptions } from \"./runner.js\";\nimport { gateCanvasExtensions } from \"./trust.js\";\n\n/** One canvas a person could open, or has open. */\nexport interface CanvasListing {\n\textensionId: string;\n\t/** Undefined until the extension has been forked, since declarations come from it. */\n\tcanvasId: string | undefined;\n\tdisplayName: string | undefined;\n\tscope: DiscoveredCanvasExtension[\"scope\"];\n\t/** Why it cannot be opened, if it cannot. */\n\twithheld: \"untrusted-workspace\" | undefined;\n\t/** Instances of this canvas that are currently open. */\n\topen: CanvasInstance[];\n}\n\n/** What `list()` reports. */\nexport interface CanvasOverview {\n\t/** Absent `reason` means canvases can run here. */\n\tavailability: CanvasAvailability;\n\tlistings: CanvasListing[];\n\t/** Extensions withheld by the trust gate — surfaced, never hidden (§5.1). */\n\twithheldCount: number;\n}\n\n/** Configuration for a session's canvas facade. */\nexport interface CanvasSessionOptions extends CanvasRegistryEvents {\n\tcwd: string;\n\thomeDir: string;\n\tagentDir?: string;\n\t/** Override the search roots; defaults to {@link canvasSearchRoots}. */\n\troots?: CanvasSearchRoot[];\n\t/** Override availability resolution, for tests and for hosts that already know. */\n\tresolveRuntime?: () => Promise<CanvasAvailability>;\n}\n\n/** Reference to a canvas: an extension id, optionally narrowed to one of its canvases. */\nexport interface CanvasRef {\n\textensionId: string;\n\tcanvasId?: string;\n}\n\n/**\n * Parse `extension` or `extension:canvas`.\n *\n * Extension ids are directory names and canvas ids are provider-local, so a single\n * colon is unambiguous and needs no quoting.\n */\nexport function parseCanvasRef(input: string): CanvasRef | undefined {\n\tconst trimmed = input.trim();\n\tif (trimmed.length === 0) return undefined;\n\tconst colon = trimmed.indexOf(\":\");\n\tif (colon === -1) return { extensionId: trimmed };\n\tconst extensionId = trimmed.slice(0, colon).trim();\n\tconst canvasId = trimmed.slice(colon + 1).trim();\n\tif (extensionId.length === 0 || canvasId.length === 0) return undefined;\n\treturn { extensionId, canvasId };\n}\n\nexport class CanvasSession {\n\tprivate readonly options: CanvasSessionOptions;\n\tprivate readonly roots: CanvasSearchRoot[];\n\t/** Cached because resolving can spawn `node --version` and cannot change mid-session. */\n\tprivate availabilityPromise: Promise<CanvasAvailability> | undefined;\n\t/** Created on first successful open, not at construction: listing must not fork. */\n\tprivate registry: CanvasRegistry | undefined;\n\n\tconstructor(options: CanvasSessionOptions) {\n\t\tthis.options = options;\n\t\tthis.roots = options.roots ?? canvasSearchRoots(options.cwd, options.homeDir);\n\t}\n\n\t/** Discovered extensions, partitioned by the trust gate. Read-only and always safe. */\n\tdiscover(): { runnable: DiscoveredCanvasExtension[]; withheld: DiscoveredCanvasExtension[] } {\n\t\tconst gated = gateCanvasExtensions(\n\t\t\tdiscoverCanvasExtensions(this.roots),\n\t\t\tthis.options.cwd,\n\t\t\tthis.options.agentDir ?? getAgentDir(),\n\t\t);\n\t\treturn { runnable: gated.runnable, withheld: gated.withheld.map((entry) => entry.extension) };\n\t}\n\n\t/** Whether canvases can run here. Resolved once per session and cached. */\n\tasync availability(): Promise<CanvasAvailability> {\n\t\tthis.availabilityPromise ??= (this.options.resolveRuntime ?? resolveCanvasRuntime)();\n\t\treturn this.availabilityPromise;\n\t}\n\n\t/**\n\t * What is installed, what is open, and what is being withheld.\n\t *\n\t * Deliberately does not fork anything: listing must stay free and safe, so a\n\t * `canvasId` is only known for extensions already running. That is the visible\n\t * consequence of a canvas having no passive half (§5.1) — even its name comes from\n\t * running its code.\n\t */\n\tasync list(): Promise<CanvasOverview> {\n\t\tconst availability = await this.availability();\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst open = this.registryOrUndefined()?.listInstances() ?? [];\n\n\t\tconst listings: CanvasListing[] = [];\n\t\tfor (const extension of runnable) {\n\t\t\tconst instances = open.filter((instance) => instance.extensionId === extension.id);\n\t\t\tif (instances.length === 0) {\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId: undefined,\n\t\t\t\t\tdisplayName: undefined,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: [],\n\t\t\t\t});\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tfor (const canvasId of new Set(instances.map((instance) => instance.canvasId))) {\n\t\t\t\tconst forCanvas = instances.filter((instance) => instance.canvasId === canvasId);\n\t\t\t\tlistings.push({\n\t\t\t\t\textensionId: extension.id,\n\t\t\t\t\tcanvasId,\n\t\t\t\t\tdisplayName: forCanvas[0]?.title,\n\t\t\t\t\tscope: extension.scope,\n\t\t\t\t\twithheld: undefined,\n\t\t\t\t\topen: forCanvas,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tfor (const extension of withheld) {\n\t\t\tlistings.push({\n\t\t\t\textensionId: extension.id,\n\t\t\t\tcanvasId: undefined,\n\t\t\t\tdisplayName: undefined,\n\t\t\t\tscope: extension.scope,\n\t\t\t\twithheld: \"untrusted-workspace\",\n\t\t\t\topen: [],\n\t\t\t});\n\t\t}\n\t\treturn { availability, listings, withheldCount: withheld.length };\n\t}\n\n\t/**\n\t * Open a canvas.\n\t *\n\t * `options.signal` comes from the caller's cancellable loader, so a person's Esc\n\t * reaches the registry's abandon path (§11.6) rather than merely hiding a spinner.\n\t */\n\tasync open(ref: CanvasRef, options?: CanvasCallOptions): Promise<CanvasInstance> {\n\t\tconst availability = await this.availability();\n\t\tif (!availability.available) throw new Error(availability.reason);\n\n\t\tconst { runnable, withheld } = this.discover();\n\t\tconst extension = runnable.find((candidate) => candidate.id === ref.extensionId);\n\t\tif (!extension) {\n\t\t\tif (withheld.some((candidate) => candidate.id === ref.extensionId)) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Canvas extension \"${ref.extensionId}\" came with this repository, which is not a trusted workspace. ` +\n\t\t\t\t\t\t\"Run /plugin trust to allow this directory to run code it ships.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst known = runnable.map((candidate) => candidate.id).join(\", \") || \"none\";\n\t\t\tthrow new Error(`No canvas extension \"${ref.extensionId}\" (found: ${known}).`);\n\t\t}\n\n\t\tconst registry = this.ensureRegistry(availability);\n\t\tconst canvasId = ref.canvasId ?? (await this.soleCanvasId(registry, extension));\n\t\treturn registry.open(extension, canvasId, undefined, options);\n\t}\n\n\t/** Close one open instance. Unknown ids are a no-op, so closing twice is harmless. */\n\tasync close(instanceId: string): Promise<CanvasInstance | undefined> {\n\t\tconst registry = this.registryOrUndefined();\n\t\tconst instance = registry?.listInstances().find((open) => open.instanceId === instanceId);\n\t\tif (!registry || !instance) return undefined;\n\t\tawait registry.close(instance);\n\t\treturn instance;\n\t}\n\n\t/** Every open instance. */\n\tinstances(): CanvasInstance[] {\n\t\treturn this.registryOrUndefined()?.listInstances() ?? [];\n\t}\n\n\t/**\n\t * The live registry, or undefined if nothing has been opened yet.\n\t *\n\t * Exposed so the host can hand it to the canvas tools, which read\n\t * `listInstances()` and `activeActions()` from it.\n\t */\n\tregistryOrUndefined(): CanvasRegistry | undefined {\n\t\treturn this.registry;\n\t}\n\n\t/** Advisory cleanup, driven by whoever owns the session clock. */\n\tasync reapIdle(): Promise<string[]> {\n\t\treturn (await this.registryOrUndefined()?.reapIdle()) ?? [];\n\t}\n\n\t/** Close everything and stop every child. Safe to call twice. */\n\tasync dispose(): Promise<void> {\n\t\tawait this.registry?.shutdown();\n\t\tthis.registry = undefined;\n\t}\n\n\tprivate ensureRegistry(availability: Extract<CanvasAvailability, { available: true }>): CanvasRegistry {\n\t\tif (!this.registry) {\n\t\t\tthis.registry = new CanvasRegistry({\n\t\t\t\truntime: availability.runtime,\n\t\t\t\tcwd: this.options.cwd,\n\t\t\t\tagentDir: this.options.agentDir,\n\t\t\t\tonLog: this.options.onLog,\n\t\t\t\tonStray: this.options.onStray,\n\t\t\t\tonStderr: this.options.onStderr,\n\t\t\t\tonDiagnostic: this.options.onDiagnostic,\n\t\t\t});\n\t\t}\n\t\treturn this.registry;\n\t}\n\n\t/**\n\t * Pick the canvas when the caller named only an extension.\n\t *\n\t * Forking to read the declarations is unavoidable: they arrive in the child's\n\t * `ready` message. A multi-canvas extension must be named explicitly rather than\n\t * guessed at.\n\t */\n\tprivate async soleCanvasId(registry: CanvasRegistry, extension: DiscoveredCanvasExtension): Promise<string> {\n\t\tconst declarations = await registry.declarations(extension);\n\t\tif (declarations.length === 1) return declarations[0]?.id as string;\n\t\tif (declarations.length === 0) throw new Error(`Canvas extension \"${extension.id}\" declares no canvases.`);\n\t\tconst ids = declarations.map((declaration) => `${extension.id}:${declaration.id}`).join(\", \");\n\t\tthrow new Error(`Canvas extension \"${extension.id}\" declares several canvases; name one of: ${ids}.`);\n\t}\n}\n"]}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust gating for canvas extensions.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/canvas-extensions-design.md` §5. The argument is already written
|
|
5
|
+
* down in `core/extensions/plugins/trust.ts`, for the same reason:
|
|
6
|
+
*
|
|
7
|
+
* > Its skills and commands are text the model reads, which is no worse than
|
|
8
|
+
* > reading the repository itself, but its **hooks and MCP servers are processes**
|
|
9
|
+
* > that start on session load.
|
|
10
|
+
*
|
|
11
|
+
* A canvas extension is a process **that also opens a listening socket**, so it
|
|
12
|
+
* belongs in that record on identical grounds and needs no new mechanism. The gate
|
|
13
|
+
* itself is shared: `isRepositorySupplied` and `shouldWithholdRepositorySupplied`
|
|
14
|
+
* live in `plugins/trust.ts` and are used by the plugin gate too, so this module
|
|
15
|
+
* supplies only the roots that are specific to canvas extensions. The record lives
|
|
16
|
+
* outside the repository, so repository content cannot forge it.
|
|
17
|
+
*
|
|
18
|
+
* One difference from plugins, and it matters. `shouldWithholdExecutables` can
|
|
19
|
+
* withhold a plugin's hooks and MCP servers while still loading its skills,
|
|
20
|
+
* because a plugin has passive capabilities worth having. **A canvas has none.**
|
|
21
|
+
* Its declaration, its actions, and its UI all come from running its code. There
|
|
22
|
+
* is nothing to partially allow, so an untrusted canvas is withheld whole.
|
|
23
|
+
*
|
|
24
|
+
* What stays available is discovery: `discovery.ts` only reads directory entries,
|
|
25
|
+
* so an untrusted canvas can still be listed, named, and offered — the person can
|
|
26
|
+
* see what is on offer and decide to trust the workspace. Listing is not running.
|
|
27
|
+
*/
|
|
28
|
+
import type { DiscoveredCanvasExtension } from "./discovery.js";
|
|
29
|
+
/** Thrown when something tries to run a canvas the workspace has not earned. */
|
|
30
|
+
export declare class CanvasTrustError extends Error {
|
|
31
|
+
readonly extensionId: string;
|
|
32
|
+
constructor(extensionId: string, cwd: string);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether an extension sits in the working tree, and therefore arrived with the
|
|
36
|
+
* repository as far as anyone but its author can tell.
|
|
37
|
+
*
|
|
38
|
+
* Both project-scope homes count. `.agents/extensions/` is where hoocode would
|
|
39
|
+
* author its own, and `.github/extensions/` is Copilot's project scope — the one
|
|
40
|
+
* that travels in every clone. Neither location can distinguish "I put this here"
|
|
41
|
+
* from "this arrived in the clone", which is exactly why location is not the
|
|
42
|
+
* question being asked; it only decides *whether* to ask about trust.
|
|
43
|
+
*
|
|
44
|
+
* User scope (`~/.copilot/extensions/`) is not project-supplied: it is outside any
|
|
45
|
+
* repository and got there by a deliberate local act.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isProjectSuppliedCanvas(extension: DiscoveredCanvasExtension, cwd: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Whether an extension must not be forked: it came with the repository and this
|
|
50
|
+
* machine has not trusted the workspace.
|
|
51
|
+
*/
|
|
52
|
+
export declare function shouldWithholdCanvas(extension: DiscoveredCanvasExtension, cwd: string, agentDir?: string): boolean;
|
|
53
|
+
/** A withheld extension, with the reason, so a caller can explain rather than hide. */
|
|
54
|
+
export interface WithheldCanvasExtension {
|
|
55
|
+
extension: DiscoveredCanvasExtension;
|
|
56
|
+
reason: "untrusted-workspace";
|
|
57
|
+
}
|
|
58
|
+
/** Discovered extensions split into what may be forked and what may not. */
|
|
59
|
+
export interface GatedCanvasExtensions {
|
|
60
|
+
runnable: DiscoveredCanvasExtension[];
|
|
61
|
+
withheld: WithheldCanvasExtension[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Partition discovered extensions by trust.
|
|
65
|
+
*
|
|
66
|
+
* Callers should present `withheld` rather than dropping it: the point of the gate
|
|
67
|
+
* is that the person can see a repository offers a canvas and choose, not that the
|
|
68
|
+
* offer disappears.
|
|
69
|
+
*/
|
|
70
|
+
export declare function gateCanvasExtensions(extensions: DiscoveredCanvasExtension[], cwd: string, agentDir?: string): GatedCanvasExtensions;
|
|
71
|
+
//# sourceMappingURL=trust.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trust.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/trust.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAEhE,gFAAgF;AAChF,qBAAa,gBAAiB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAE7B,YAAY,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAQ3C;CACD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,yBAAyB,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAElG;AAOD;;;GAGG;AACH,wBAAgB,oBAAoB,CACnC,SAAS,EAAE,yBAAyB,EACpC,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAsB,GAC9B,OAAO,CAET;AAED,uFAAuF;AACvF,MAAM,WAAW,uBAAuB;IACvC,SAAS,EAAE,yBAAyB,CAAC;IACrC,MAAM,EAAE,qBAAqB,CAAC;CAC9B;AAED,4EAA4E;AAC5E,MAAM,WAAW,qBAAqB;IACrC,QAAQ,EAAE,yBAAyB,EAAE,CAAC;IACtC,QAAQ,EAAE,uBAAuB,EAAE,CAAC;CACpC;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CACnC,UAAU,EAAE,yBAAyB,EAAE,EACvC,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAsB,GAC9B,qBAAqB,CAUvB","sourcesContent":["/**\n * Trust gating for canvas extensions.\n *\n * Design: `docs/canvas-extensions-design.md` §5. The argument is already written\n * down in `core/extensions/plugins/trust.ts`, for the same reason:\n *\n * > Its skills and commands are text the model reads, which is no worse than\n * > reading the repository itself, but its **hooks and MCP servers are processes**\n * > that start on session load.\n *\n * A canvas extension is a process **that also opens a listening socket**, so it\n * belongs in that record on identical grounds and needs no new mechanism. The gate\n * itself is shared: `isRepositorySupplied` and `shouldWithholdRepositorySupplied`\n * live in `plugins/trust.ts` and are used by the plugin gate too, so this module\n * supplies only the roots that are specific to canvas extensions. The record lives\n * outside the repository, so repository content cannot forge it.\n *\n * One difference from plugins, and it matters. `shouldWithholdExecutables` can\n * withhold a plugin's hooks and MCP servers while still loading its skills,\n * because a plugin has passive capabilities worth having. **A canvas has none.**\n * Its declaration, its actions, and its UI all come from running its code. There\n * is nothing to partially allow, so an untrusted canvas is withheld whole.\n *\n * What stays available is discovery: `discovery.ts` only reads directory entries,\n * so an untrusted canvas can still be listed, named, and offered — the person can\n * see what is on offer and decide to trust the workspace. Listing is not running.\n */\n\nimport * as path from \"node:path\";\nimport { getAgentDir } from \"../../config.js\";\nimport { isRepositorySupplied, shouldWithholdRepositorySupplied } from \"../extensions/plugins/trust.js\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\n\n/** Thrown when something tries to run a canvas the workspace has not earned. */\nexport class CanvasTrustError extends Error {\n\treadonly extensionId: string;\n\n\tconstructor(extensionId: string, cwd: string) {\n\t\tsuper(\n\t\t\t`Canvas extension \"${extensionId}\" came with this repository and \"${cwd}\" is not a trusted workspace. ` +\n\t\t\t\t`Running it would start a process and open a listening socket on your machine. ` +\n\t\t\t\t`Trust the workspace first if that is what you want.`,\n\t\t);\n\t\tthis.name = \"CanvasTrustError\";\n\t\tthis.extensionId = extensionId;\n\t}\n}\n\n/**\n * Whether an extension sits in the working tree, and therefore arrived with the\n * repository as far as anyone but its author can tell.\n *\n * Both project-scope homes count. `.agents/extensions/` is where hoocode would\n * author its own, and `.github/extensions/` is Copilot's project scope — the one\n * that travels in every clone. Neither location can distinguish \"I put this here\"\n * from \"this arrived in the clone\", which is exactly why location is not the\n * question being asked; it only decides *whether* to ask about trust.\n *\n * User scope (`~/.copilot/extensions/`) is not project-supplied: it is outside any\n * repository and got there by a deliberate local act.\n */\nexport function isProjectSuppliedCanvas(extension: DiscoveredCanvasExtension, cwd: string): boolean {\n\treturn isRepositorySupplied(extension.dir, canvasProjectScopeRoots(cwd));\n}\n\n/** A canvas extension's project-scope homes — the locations that travel with a clone. */\nfunction canvasProjectScopeRoots(cwd: string): string[] {\n\treturn [path.join(cwd, \".agents\", \"extensions\"), path.join(cwd, \".github\", \"extensions\")];\n}\n\n/**\n * Whether an extension must not be forked: it came with the repository and this\n * machine has not trusted the workspace.\n */\nexport function shouldWithholdCanvas(\n\textension: DiscoveredCanvasExtension,\n\tcwd: string,\n\tagentDir: string = getAgentDir(),\n): boolean {\n\treturn shouldWithholdRepositorySupplied(extension.dir, cwd, canvasProjectScopeRoots(cwd), agentDir);\n}\n\n/** A withheld extension, with the reason, so a caller can explain rather than hide. */\nexport interface WithheldCanvasExtension {\n\textension: DiscoveredCanvasExtension;\n\treason: \"untrusted-workspace\";\n}\n\n/** Discovered extensions split into what may be forked and what may not. */\nexport interface GatedCanvasExtensions {\n\trunnable: DiscoveredCanvasExtension[];\n\twithheld: WithheldCanvasExtension[];\n}\n\n/**\n * Partition discovered extensions by trust.\n *\n * Callers should present `withheld` rather than dropping it: the point of the gate\n * is that the person can see a repository offers a canvas and choose, not that the\n * offer disappears.\n */\nexport function gateCanvasExtensions(\n\textensions: DiscoveredCanvasExtension[],\n\tcwd: string,\n\tagentDir: string = getAgentDir(),\n): GatedCanvasExtensions {\n\tconst gated: GatedCanvasExtensions = { runnable: [], withheld: [] };\n\tfor (const extension of extensions) {\n\t\tif (shouldWithholdCanvas(extension, cwd, agentDir)) {\n\t\t\tgated.withheld.push({ extension, reason: \"untrusted-workspace\" });\n\t\t} else {\n\t\t\tgated.runnable.push(extension);\n\t\t}\n\t}\n\treturn gated;\n}\n"]}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust gating for canvas extensions.
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/canvas-extensions-design.md` §5. The argument is already written
|
|
5
|
+
* down in `core/extensions/plugins/trust.ts`, for the same reason:
|
|
6
|
+
*
|
|
7
|
+
* > Its skills and commands are text the model reads, which is no worse than
|
|
8
|
+
* > reading the repository itself, but its **hooks and MCP servers are processes**
|
|
9
|
+
* > that start on session load.
|
|
10
|
+
*
|
|
11
|
+
* A canvas extension is a process **that also opens a listening socket**, so it
|
|
12
|
+
* belongs in that record on identical grounds and needs no new mechanism. The gate
|
|
13
|
+
* itself is shared: `isRepositorySupplied` and `shouldWithholdRepositorySupplied`
|
|
14
|
+
* live in `plugins/trust.ts` and are used by the plugin gate too, so this module
|
|
15
|
+
* supplies only the roots that are specific to canvas extensions. The record lives
|
|
16
|
+
* outside the repository, so repository content cannot forge it.
|
|
17
|
+
*
|
|
18
|
+
* One difference from plugins, and it matters. `shouldWithholdExecutables` can
|
|
19
|
+
* withhold a plugin's hooks and MCP servers while still loading its skills,
|
|
20
|
+
* because a plugin has passive capabilities worth having. **A canvas has none.**
|
|
21
|
+
* Its declaration, its actions, and its UI all come from running its code. There
|
|
22
|
+
* is nothing to partially allow, so an untrusted canvas is withheld whole.
|
|
23
|
+
*
|
|
24
|
+
* What stays available is discovery: `discovery.ts` only reads directory entries,
|
|
25
|
+
* so an untrusted canvas can still be listed, named, and offered — the person can
|
|
26
|
+
* see what is on offer and decide to trust the workspace. Listing is not running.
|
|
27
|
+
*/
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import { getAgentDir } from "../../config.js";
|
|
30
|
+
import { isRepositorySupplied, shouldWithholdRepositorySupplied } from "../extensions/plugins/trust.js";
|
|
31
|
+
/** Thrown when something tries to run a canvas the workspace has not earned. */
|
|
32
|
+
export class CanvasTrustError extends Error {
|
|
33
|
+
extensionId;
|
|
34
|
+
constructor(extensionId, cwd) {
|
|
35
|
+
super(`Canvas extension "${extensionId}" came with this repository and "${cwd}" is not a trusted workspace. ` +
|
|
36
|
+
`Running it would start a process and open a listening socket on your machine. ` +
|
|
37
|
+
`Trust the workspace first if that is what you want.`);
|
|
38
|
+
this.name = "CanvasTrustError";
|
|
39
|
+
this.extensionId = extensionId;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether an extension sits in the working tree, and therefore arrived with the
|
|
44
|
+
* repository as far as anyone but its author can tell.
|
|
45
|
+
*
|
|
46
|
+
* Both project-scope homes count. `.agents/extensions/` is where hoocode would
|
|
47
|
+
* author its own, and `.github/extensions/` is Copilot's project scope — the one
|
|
48
|
+
* that travels in every clone. Neither location can distinguish "I put this here"
|
|
49
|
+
* from "this arrived in the clone", which is exactly why location is not the
|
|
50
|
+
* question being asked; it only decides *whether* to ask about trust.
|
|
51
|
+
*
|
|
52
|
+
* User scope (`~/.copilot/extensions/`) is not project-supplied: it is outside any
|
|
53
|
+
* repository and got there by a deliberate local act.
|
|
54
|
+
*/
|
|
55
|
+
export function isProjectSuppliedCanvas(extension, cwd) {
|
|
56
|
+
return isRepositorySupplied(extension.dir, canvasProjectScopeRoots(cwd));
|
|
57
|
+
}
|
|
58
|
+
/** A canvas extension's project-scope homes — the locations that travel with a clone. */
|
|
59
|
+
function canvasProjectScopeRoots(cwd) {
|
|
60
|
+
return [path.join(cwd, ".agents", "extensions"), path.join(cwd, ".github", "extensions")];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Whether an extension must not be forked: it came with the repository and this
|
|
64
|
+
* machine has not trusted the workspace.
|
|
65
|
+
*/
|
|
66
|
+
export function shouldWithholdCanvas(extension, cwd, agentDir = getAgentDir()) {
|
|
67
|
+
return shouldWithholdRepositorySupplied(extension.dir, cwd, canvasProjectScopeRoots(cwd), agentDir);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Partition discovered extensions by trust.
|
|
71
|
+
*
|
|
72
|
+
* Callers should present `withheld` rather than dropping it: the point of the gate
|
|
73
|
+
* is that the person can see a repository offers a canvas and choose, not that the
|
|
74
|
+
* offer disappears.
|
|
75
|
+
*/
|
|
76
|
+
export function gateCanvasExtensions(extensions, cwd, agentDir = getAgentDir()) {
|
|
77
|
+
const gated = { runnable: [], withheld: [] };
|
|
78
|
+
for (const extension of extensions) {
|
|
79
|
+
if (shouldWithholdCanvas(extension, cwd, agentDir)) {
|
|
80
|
+
gated.withheld.push({ extension, reason: "untrusted-workspace" });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
gated.runnable.push(extension);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return gated;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=trust.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trust.js","sourceRoot":"","sources":["../../../src/core/canvas/trust.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAE,oBAAoB,EAAE,gCAAgC,EAAE,MAAM,gCAAgC,CAAC;AAGxG,gFAAgF;AAChF,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACjC,WAAW,CAAS;IAE7B,YAAY,WAAmB,EAAE,GAAW,EAAE;QAC7C,KAAK,CACJ,qBAAqB,WAAW,oCAAoC,GAAG,gCAAgC;YACtG,gFAAgF;YAChF,qDAAqD,CACtD,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;QAC/B,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;IAAA,CAC/B;CACD;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,uBAAuB,CAAC,SAAoC,EAAE,GAAW,EAAW;IACnG,OAAO,oBAAoB,CAAC,SAAS,CAAC,GAAG,EAAE,uBAAuB,CAAC,GAAG,CAAC,CAAC,CAAC;AAAA,CACzE;AAED,2FAAyF;AACzF,SAAS,uBAAuB,CAAC,GAAW,EAAY;IACvD,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,YAAY,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;AAAA,CAC1F;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CACnC,SAAoC,EACpC,GAAW,EACX,QAAQ,GAAW,WAAW,EAAE,EACtB;IACV,OAAO,gCAAgC,CAAC,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,uBAAuB,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,CAAC;AAAA,CACpG;AAcD;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB,CACnC,UAAuC,EACvC,GAAW,EACX,QAAQ,GAAW,WAAW,EAAE,EACR;IACxB,MAAM,KAAK,GAA0B,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;IACpE,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACpC,IAAI,oBAAoB,CAAC,SAAS,EAAE,GAAG,EAAE,QAAQ,CAAC,EAAE,CAAC;YACpD,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,qBAAqB,EAAE,CAAC,CAAC;QACnE,CAAC;aAAM,CAAC;YACP,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAChC,CAAC;IACF,CAAC;IACD,OAAO,KAAK,CAAC;AAAA,CACb","sourcesContent":["/**\n * Trust gating for canvas extensions.\n *\n * Design: `docs/canvas-extensions-design.md` §5. The argument is already written\n * down in `core/extensions/plugins/trust.ts`, for the same reason:\n *\n * > Its skills and commands are text the model reads, which is no worse than\n * > reading the repository itself, but its **hooks and MCP servers are processes**\n * > that start on session load.\n *\n * A canvas extension is a process **that also opens a listening socket**, so it\n * belongs in that record on identical grounds and needs no new mechanism. The gate\n * itself is shared: `isRepositorySupplied` and `shouldWithholdRepositorySupplied`\n * live in `plugins/trust.ts` and are used by the plugin gate too, so this module\n * supplies only the roots that are specific to canvas extensions. The record lives\n * outside the repository, so repository content cannot forge it.\n *\n * One difference from plugins, and it matters. `shouldWithholdExecutables` can\n * withhold a plugin's hooks and MCP servers while still loading its skills,\n * because a plugin has passive capabilities worth having. **A canvas has none.**\n * Its declaration, its actions, and its UI all come from running its code. There\n * is nothing to partially allow, so an untrusted canvas is withheld whole.\n *\n * What stays available is discovery: `discovery.ts` only reads directory entries,\n * so an untrusted canvas can still be listed, named, and offered — the person can\n * see what is on offer and decide to trust the workspace. Listing is not running.\n */\n\nimport * as path from \"node:path\";\nimport { getAgentDir } from \"../../config.js\";\nimport { isRepositorySupplied, shouldWithholdRepositorySupplied } from \"../extensions/plugins/trust.js\";\nimport type { DiscoveredCanvasExtension } from \"./discovery.js\";\n\n/** Thrown when something tries to run a canvas the workspace has not earned. */\nexport class CanvasTrustError extends Error {\n\treadonly extensionId: string;\n\n\tconstructor(extensionId: string, cwd: string) {\n\t\tsuper(\n\t\t\t`Canvas extension \"${extensionId}\" came with this repository and \"${cwd}\" is not a trusted workspace. ` +\n\t\t\t\t`Running it would start a process and open a listening socket on your machine. ` +\n\t\t\t\t`Trust the workspace first if that is what you want.`,\n\t\t);\n\t\tthis.name = \"CanvasTrustError\";\n\t\tthis.extensionId = extensionId;\n\t}\n}\n\n/**\n * Whether an extension sits in the working tree, and therefore arrived with the\n * repository as far as anyone but its author can tell.\n *\n * Both project-scope homes count. `.agents/extensions/` is where hoocode would\n * author its own, and `.github/extensions/` is Copilot's project scope — the one\n * that travels in every clone. Neither location can distinguish \"I put this here\"\n * from \"this arrived in the clone\", which is exactly why location is not the\n * question being asked; it only decides *whether* to ask about trust.\n *\n * User scope (`~/.copilot/extensions/`) is not project-supplied: it is outside any\n * repository and got there by a deliberate local act.\n */\nexport function isProjectSuppliedCanvas(extension: DiscoveredCanvasExtension, cwd: string): boolean {\n\treturn isRepositorySupplied(extension.dir, canvasProjectScopeRoots(cwd));\n}\n\n/** A canvas extension's project-scope homes — the locations that travel with a clone. */\nfunction canvasProjectScopeRoots(cwd: string): string[] {\n\treturn [path.join(cwd, \".agents\", \"extensions\"), path.join(cwd, \".github\", \"extensions\")];\n}\n\n/**\n * Whether an extension must not be forked: it came with the repository and this\n * machine has not trusted the workspace.\n */\nexport function shouldWithholdCanvas(\n\textension: DiscoveredCanvasExtension,\n\tcwd: string,\n\tagentDir: string = getAgentDir(),\n): boolean {\n\treturn shouldWithholdRepositorySupplied(extension.dir, cwd, canvasProjectScopeRoots(cwd), agentDir);\n}\n\n/** A withheld extension, with the reason, so a caller can explain rather than hide. */\nexport interface WithheldCanvasExtension {\n\textension: DiscoveredCanvasExtension;\n\treason: \"untrusted-workspace\";\n}\n\n/** Discovered extensions split into what may be forked and what may not. */\nexport interface GatedCanvasExtensions {\n\trunnable: DiscoveredCanvasExtension[];\n\twithheld: WithheldCanvasExtension[];\n}\n\n/**\n * Partition discovered extensions by trust.\n *\n * Callers should present `withheld` rather than dropping it: the point of the gate\n * is that the person can see a repository offers a canvas and choose, not that the\n * offer disappears.\n */\nexport function gateCanvasExtensions(\n\textensions: DiscoveredCanvasExtension[],\n\tcwd: string,\n\tagentDir: string = getAgentDir(),\n): GatedCanvasExtensions {\n\tconst gated: GatedCanvasExtensions = { runnable: [], withheld: [] };\n\tfor (const extension of extensions) {\n\t\tif (shouldWithholdCanvas(extension, cwd, agentDir)) {\n\t\t\tgated.withheld.push({ extension, reason: \"untrusted-workspace\" });\n\t\t} else {\n\t\t\tgated.runnable.push(extension);\n\t\t}\n\t}\n\treturn gated;\n}\n"]}
|
|
@@ -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;AAOhE,OAAO,KAAK,EACX,SAAS,EAET,gBAAgB,EAChB,kBAAkB,EAClB,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;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAMhF;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,OAAO,CAEpH;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,kBAAkB,EAAE,CAAA;CAAE,CAAC,CA8CpE","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 { isWorkspaceTrusted } from \"./plugins/trust.js\";\nimport type {\n\tExtension,\n\tExtensionAPI,\n\tExtensionFactory,\n\tExtensionLoadIssue,\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 sits in the working tree, and therefore came with\n * the repository as far as anyone but its installer can tell.\n *\n * All three project paths count: `<cwd>/.claude/skills` (the vendor convention\n * for repo-committed plugins) and `<cwd>/.agents/plugins` + `<cwd>/.hoocode/plugins`\n * (hoocode's own project-scope install homes). An earlier version listed only the\n * first, reasoning that the hoocode homes held plugins \"the user installed\n * deliberately\" — true of the person who ran the install, and false for every\n * collaborator who clones the result. Location cannot tell those two apart, so it\n * is the wrong thing to ask; {@link isWorkspaceTrusted} asks the right one.\n */\nexport function isProjectSuppliedPlugin(pluginRoot: string, cwd: string): boolean {\n\treturn [\n\t\tpath.join(cwd, \".claude\", \"skills\"),\n\t\tpath.join(cwd, \".agents\", \"plugins\"),\n\t\tpath.join(cwd, CONFIG_DIR_NAME, \"plugins\"),\n\t].some((root) => isUnderDir(pluginRoot, root));\n}\n\n/**\n * Whether a plugin's **executable** capabilities (hooks, MCP servers) should be\n * withheld: it lives in the working tree and this machine has not trusted the\n * workspace.\n *\n * Passive capabilities always load. Reading a repository's skill text is what\n * opening the repository already implies; starting its processes is not.\n */\nexport function shouldWithholdExecutables(pluginRoot: string, cwd: string, agentDir: string = getAgentDir()): boolean {\n\treturn isProjectSuppliedPlugin(pluginRoot, cwd) && !isWorkspaceTrusted(cwd, agentDir);\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: ExtensionLoadIssue[] }> {\n\tclearExtensionMcpServers();\n\tconst extensions: Extension[] = [];\n\tconst errors: ExtensionLoadIssue[] = [];\n\n\tfor (const plugin of discoverPlugins(pluginDirs)) {\n\t\ttry {\n\t\t\t// Withhold the executable half of a plugin that lives in the working tree\n\t\t\t// until this machine has trusted the workspace. Skills, commands and\n\t\t\t// subagents still load — reading a repository's text is what opening it\n\t\t\t// already implies. See PluginFactoryOptions.passiveOnly.\n\t\t\tconst passiveOnly = shouldWithholdExecutables(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\t// A warning, not an error: the plugin *did* load, minus its executable\n\t\t\t\t// half, and the session must reach the prompt for `/plugin trust` to be\n\t\t\t\t// runnable at all. Reporting this as an error aborted startup, which\n\t\t\t\t// left the only remedy behind a door it had just locked.\n\t\t\t\terrors.push({\n\t\t\t\t\tpath: plugin.manifestPath,\n\t\t\t\t\tseverity: \"warning\",\n\t\t\t\t\terror:\n\t\t\t\t\t\t`Plugin \"${plugin.id}\" is in the working tree: ${withheld.join(\" and \")} not loaded. ` +\n\t\t\t\t\t\t\"Code committed to a repository runs for whoever clones it, so hoocode does not start it \" +\n\t\t\t\t\t\t\"until you say this directory is yours to run code from. Run `/plugin trust` to allow it here, \" +\n\t\t\t\t\t\t\"or install the plugin at user scope instead.\",\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"]}
|
|
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;AAOhE,OAAO,KAAK,EACX,SAAS,EAET,gBAAgB,EAChB,kBAAkB,EAClB,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;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAEhF;AAWD;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAsB,GAAG,OAAO,CAEpH;AAED;;;;;;;;;;;;;;;;;;;;;;;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,kBAAkB,EAAE,CAAA;CAAE,CAAC,CA8CpE","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 { isRepositorySupplied, shouldWithholdRepositorySupplied } from \"./plugins/trust.js\";\nimport type {\n\tExtension,\n\tExtensionAPI,\n\tExtensionFactory,\n\tExtensionLoadIssue,\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 sits in the working tree, and therefore came with\n * the repository as far as anyone but its installer can tell.\n *\n * All three project paths count: `<cwd>/.claude/skills` (the vendor convention\n * for repo-committed plugins) and `<cwd>/.agents/plugins` + `<cwd>/.hoocode/plugins`\n * (hoocode's own project-scope install homes). An earlier version listed only the\n * first, reasoning that the hoocode homes held plugins \"the user installed\n * deliberately\" — true of the person who ran the install, and false for every\n * collaborator who clones the result. Location cannot tell those two apart, so it\n * is the wrong thing to ask; {@link isWorkspaceTrusted} asks the right one.\n */\nexport function isProjectSuppliedPlugin(pluginRoot: string, cwd: string): boolean {\n\treturn isRepositorySupplied(pluginRoot, pluginProjectScopeRoots(cwd));\n}\n\n/** A plugin's project-scope homes — the locations that travel with a clone. */\nfunction pluginProjectScopeRoots(cwd: string): string[] {\n\treturn [\n\t\tpath.join(cwd, \".claude\", \"skills\"),\n\t\tpath.join(cwd, \".agents\", \"plugins\"),\n\t\tpath.join(cwd, CONFIG_DIR_NAME, \"plugins\"),\n\t];\n}\n\n/**\n * Whether a plugin's **executable** capabilities (hooks, MCP servers) should be\n * withheld: it lives in the working tree and this machine has not trusted the\n * workspace.\n *\n * Passive capabilities always load. Reading a repository's skill text is what\n * opening the repository already implies; starting its processes is not.\n */\nexport function shouldWithholdExecutables(pluginRoot: string, cwd: string, agentDir: string = getAgentDir()): boolean {\n\treturn shouldWithholdRepositorySupplied(pluginRoot, cwd, pluginProjectScopeRoots(cwd), agentDir);\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: ExtensionLoadIssue[] }> {\n\tclearExtensionMcpServers();\n\tconst extensions: Extension[] = [];\n\tconst errors: ExtensionLoadIssue[] = [];\n\n\tfor (const plugin of discoverPlugins(pluginDirs)) {\n\t\ttry {\n\t\t\t// Withhold the executable half of a plugin that lives in the working tree\n\t\t\t// until this machine has trusted the workspace. Skills, commands and\n\t\t\t// subagents still load — reading a repository's text is what opening it\n\t\t\t// already implies. See PluginFactoryOptions.passiveOnly.\n\t\t\tconst passiveOnly = shouldWithholdExecutables(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\t// A warning, not an error: the plugin *did* load, minus its executable\n\t\t\t\t// half, and the session must reach the prompt for `/plugin trust` to be\n\t\t\t\t// runnable at all. Reporting this as an error aborted startup, which\n\t\t\t\t// left the only remedy behind a door it had just locked.\n\t\t\t\terrors.push({\n\t\t\t\t\tpath: plugin.manifestPath,\n\t\t\t\t\tseverity: \"warning\",\n\t\t\t\t\terror:\n\t\t\t\t\t\t`Plugin \"${plugin.id}\" is in the working tree: ${withheld.join(\" and \")} not loaded. ` +\n\t\t\t\t\t\t\"Code committed to a repository runs for whoever clones it, so hoocode does not start it \" +\n\t\t\t\t\t\t\"until you say this directory is yours to run code from. Run `/plugin trust` to allow it here, \" +\n\t\t\t\t\t\t\"or install the plugin at user scope instead.\",\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"]}
|
|
@@ -27,7 +27,7 @@ import { execCommand } from "../exec.js";
|
|
|
27
27
|
import { clearExtensionMcpServers } from "../extension-mcp-servers.js";
|
|
28
28
|
import { createSyntheticSourceInfo } from "../source-info.js";
|
|
29
29
|
import { buildPluginFactory, discoverPlugins, pluginExtensionPath, withheldCapabilities } from "./plugins/index.js";
|
|
30
|
-
import {
|
|
30
|
+
import { isRepositorySupplied, shouldWithholdRepositorySupplied } from "./plugins/trust.js";
|
|
31
31
|
/** Modules available to extensions via virtualModules (for compiled Bun binary) */
|
|
32
32
|
const VIRTUAL_MODULES = {
|
|
33
33
|
typebox: _bundledTypebox,
|
|
@@ -533,11 +533,15 @@ export async function discoverAndLoadExtensions(configuredPaths, cwd, agentDir =
|
|
|
533
533
|
* is the wrong thing to ask; {@link isWorkspaceTrusted} asks the right one.
|
|
534
534
|
*/
|
|
535
535
|
export function isProjectSuppliedPlugin(pluginRoot, cwd) {
|
|
536
|
+
return isRepositorySupplied(pluginRoot, pluginProjectScopeRoots(cwd));
|
|
537
|
+
}
|
|
538
|
+
/** A plugin's project-scope homes — the locations that travel with a clone. */
|
|
539
|
+
function pluginProjectScopeRoots(cwd) {
|
|
536
540
|
return [
|
|
537
541
|
path.join(cwd, ".claude", "skills"),
|
|
538
542
|
path.join(cwd, ".agents", "plugins"),
|
|
539
543
|
path.join(cwd, CONFIG_DIR_NAME, "plugins"),
|
|
540
|
-
]
|
|
544
|
+
];
|
|
541
545
|
}
|
|
542
546
|
/**
|
|
543
547
|
* Whether a plugin's **executable** capabilities (hooks, MCP servers) should be
|
|
@@ -548,14 +552,7 @@ export function isProjectSuppliedPlugin(pluginRoot, cwd) {
|
|
|
548
552
|
* opening the repository already implies; starting its processes is not.
|
|
549
553
|
*/
|
|
550
554
|
export function shouldWithholdExecutables(pluginRoot, cwd, agentDir = getAgentDir()) {
|
|
551
|
-
return
|
|
552
|
-
}
|
|
553
|
-
/** True when `target` is `root` or sits inside it. */
|
|
554
|
-
function isUnderDir(target, root) {
|
|
555
|
-
const normalized = path.resolve(root);
|
|
556
|
-
if (path.resolve(target) === normalized)
|
|
557
|
-
return true;
|
|
558
|
-
return path.resolve(target).startsWith(normalized.endsWith(path.sep) ? normalized : `${normalized}${path.sep}`);
|
|
555
|
+
return shouldWithholdRepositorySupplied(pluginRoot, cwd, pluginProjectScopeRoots(cwd), agentDir);
|
|
559
556
|
}
|
|
560
557
|
/**
|
|
561
558
|
* Standard plugin discovery directories, highest precedence first.
|