@clinebot/core 0.0.33 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/auth/client.d.ts +19 -0
  2. package/dist/auth/client.d.ts.map +1 -1
  3. package/dist/auth/cline.d.ts.map +1 -1
  4. package/dist/auth/oca.d.ts.map +1 -1
  5. package/dist/auth/server.d.ts +32 -0
  6. package/dist/auth/server.d.ts.map +1 -1
  7. package/dist/auth/types.d.ts +29 -0
  8. package/dist/auth/types.d.ts.map +1 -1
  9. package/dist/extensions/index.d.ts +2 -1
  10. package/dist/extensions/index.d.ts.map +1 -1
  11. package/dist/extensions/plugin/plugin-config-loader.d.ts +2 -1
  12. package/dist/extensions/plugin/plugin-config-loader.d.ts.map +1 -1
  13. package/dist/extensions/plugin/plugin-load-report.d.ts +19 -0
  14. package/dist/extensions/plugin/plugin-load-report.d.ts.map +1 -0
  15. package/dist/extensions/plugin/plugin-loader.d.ts +6 -0
  16. package/dist/extensions/plugin/plugin-loader.d.ts.map +1 -1
  17. package/dist/extensions/plugin/plugin-sandbox.d.ts +2 -1
  18. package/dist/extensions/plugin/plugin-sandbox.d.ts.map +1 -1
  19. package/dist/extensions/plugin-sandbox-bootstrap.js +148 -148
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +207 -207
  23. package/dist/runtime/runtime-builder.d.ts +1 -1
  24. package/dist/runtime/runtime-builder.d.ts.map +1 -1
  25. package/dist/runtime/subprocess-sandbox.d.ts +2 -0
  26. package/dist/runtime/subprocess-sandbox.d.ts.map +1 -1
  27. package/dist/runtime/tool-approval.d.ts.map +1 -1
  28. package/dist/session/default-session-manager.d.ts.map +1 -1
  29. package/dist/session/persistence-service.d.ts.map +1 -1
  30. package/dist/session/session-artifacts.d.ts +2 -0
  31. package/dist/session/session-artifacts.d.ts.map +1 -1
  32. package/dist/session/session-config-builder.d.ts.map +1 -1
  33. package/dist/team/team-tools.d.ts.map +1 -1
  34. package/dist/types/config.d.ts +1 -0
  35. package/dist/types/config.d.ts.map +1 -1
  36. package/package.json +4 -4
  37. package/src/auth/client.test.ts +29 -0
  38. package/src/auth/client.ts +21 -0
  39. package/src/auth/cline.ts +2 -0
  40. package/src/auth/oca.ts +2 -0
  41. package/src/auth/server.test.ts +287 -0
  42. package/src/auth/server.ts +50 -1
  43. package/src/auth/types.ts +29 -0
  44. package/src/extensions/index.ts +6 -0
  45. package/src/extensions/plugin/plugin-config-loader.test.ts +37 -0
  46. package/src/extensions/plugin/plugin-config-loader.ts +18 -10
  47. package/src/extensions/plugin/plugin-load-report.ts +20 -0
  48. package/src/extensions/plugin/plugin-loader.test.ts +45 -0
  49. package/src/extensions/plugin/plugin-loader.ts +57 -3
  50. package/src/extensions/plugin/plugin-sandbox-bootstrap.ts +158 -86
  51. package/src/extensions/plugin/plugin-sandbox.test.ts +70 -0
  52. package/src/extensions/plugin/plugin-sandbox.ts +17 -6
  53. package/src/index.ts +11 -0
  54. package/src/runtime/hook-file-hooks.test.ts +42 -7
  55. package/src/runtime/runtime-builder.test.ts +98 -0
  56. package/src/runtime/runtime-builder.ts +112 -65
  57. package/src/runtime/subprocess-sandbox.ts +26 -23
  58. package/src/runtime/tool-approval.ts +13 -15
  59. package/src/session/default-session-manager.ts +1 -3
  60. package/src/session/persistence-service.test.ts +38 -0
  61. package/src/session/persistence-service.ts +16 -1
  62. package/src/session/session-artifacts.ts +16 -0
  63. package/src/session/session-config-builder.ts +46 -0
  64. package/src/team/team-tools.test.ts +104 -0
  65. package/src/team/team-tools.ts +35 -16
  66. package/src/types/config.ts +1 -0
  67. package/dist/runtime/team-runtime-registry.d.ts +0 -13
  68. package/dist/runtime/team-runtime-registry.d.ts.map +0 -1
  69. package/src/runtime/team-runtime-registry.ts +0 -43
package/src/auth/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ITelemetryService } from "@clinebot/shared";
2
+ import type { OAuthServerCloseInfo, OAuthServerListeningInfo } from "./server";
2
3
 
3
4
  export interface OAuthPrompt {
4
5
  message: string;
@@ -31,6 +32,34 @@ export interface OAuthLoginCallbacks {
31
32
  onPrompt: (prompt: OAuthPrompt) => Promise<string>;
32
33
  onProgress?: (message: string) => void;
33
34
  onManualCodeInput?: () => Promise<string>;
35
+ /**
36
+ * Called when the local OAuth redirect server successfully binds to a port
37
+ * and is ready to receive the browser callback. The `info` object contains
38
+ * the host, the bound port number, and the full `callbackUrl`.
39
+ *
40
+ * Use this to:
41
+ * - Show a "waiting for OAuth callback on port N" status indicator in your UI.
42
+ * - Forward the port in remote-development environments (e.g. JetBrains
43
+ * Gateway) from the remote machine to the machine running the browser.
44
+ *
45
+ * Paired with `onServerClose` for teardown.
46
+ *
47
+ * Only fired when the provider uses a local callback server
48
+ * (`OAuthProviderInterface.usesCallbackServer === true`).
49
+ */
50
+ onServerListening?: (info: OAuthServerListeningInfo) => void | Promise<void>;
51
+ /**
52
+ * Called when the local OAuth redirect server closes — either because the
53
+ * callback was received, the flow was cancelled, or the timeout elapsed.
54
+ *
55
+ * Use this to:
56
+ * - Clear any "waiting for callback" status UI shown in `onServerListening`.
57
+ * - Tear down port-forwards set up in `onServerListening`.
58
+ *
59
+ * Only fired when the provider uses a local callback server
60
+ * (`OAuthProviderInterface.usesCallbackServer === true`).
61
+ */
62
+ onServerClose?: (info: OAuthServerCloseInfo) => void | Promise<void>;
34
63
  }
35
64
 
36
65
  export interface OAuthProviderInterface {
@@ -5,8 +5,14 @@ export {
5
5
  resolveAndLoadAgentPlugins,
6
6
  resolvePluginConfigSearchPaths,
7
7
  } from "./plugin/plugin-config-loader";
8
+ export type {
9
+ PluginInitializationFailure,
10
+ PluginInitializationWarning,
11
+ PluginLoadDiagnostics,
12
+ } from "./plugin/plugin-load-report";
8
13
  export type { LoadAgentPluginFromPathOptions } from "./plugin/plugin-loader";
9
14
  export {
10
15
  loadAgentPluginFromPath,
11
16
  loadAgentPluginsFromPaths,
17
+ loadAgentPluginsFromPathsWithDiagnostics,
12
18
  } from "./plugin/plugin-loader";
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
6
6
  import {
7
7
  discoverPluginModulePaths,
8
8
  resolveAgentPluginPaths,
9
+ resolveAndLoadAgentPlugins,
9
10
  resolvePluginConfigSearchPaths,
10
11
  } from "./plugin-config-loader";
11
12
 
@@ -142,4 +143,40 @@ describe("plugin-config-loader", () => {
142
143
  await rm(workspace, { recursive: true, force: true });
143
144
  }
144
145
  });
146
+
147
+ it("loads valid plugins while reporting failures and duplicate overrides", async () => {
148
+ const root = await mkdtemp(join(tmpdir(), "core-plugin-config-loader-"));
149
+ try {
150
+ const first = join(root, "duplicate-one.js");
151
+ const second = join(root, "duplicate-two.js");
152
+ const invalid = join(root, "invalid.js");
153
+ await writeFile(
154
+ first,
155
+ "export default { name: 'duplicate-plugin', manifest: { capabilities: ['tools'] } };",
156
+ "utf8",
157
+ );
158
+ await writeFile(
159
+ second,
160
+ "export default { name: 'duplicate-plugin', manifest: { capabilities: ['commands'] } };",
161
+ "utf8",
162
+ );
163
+ await writeFile(invalid, "export default { name: 'broken' };", "utf8");
164
+
165
+ const loaded = await resolveAndLoadAgentPlugins({
166
+ mode: "in_process",
167
+ pluginPaths: [first, invalid, second],
168
+ cwd: root,
169
+ });
170
+
171
+ expect(loaded.extensions.map((plugin) => plugin.name)).toEqual([
172
+ "duplicate-plugin",
173
+ ]);
174
+ expect(loaded.extensions[0]?.manifest.capabilities).toEqual(["commands"]);
175
+ expect(loaded.failures).toHaveLength(1);
176
+ expect(loaded.warnings).toHaveLength(1);
177
+ expect(loaded.warnings[0]?.overriddenPluginPath).toBe(first);
178
+ } finally {
179
+ await rm(root, { recursive: true, force: true });
180
+ }
181
+ });
145
182
  });
@@ -5,7 +5,8 @@ import {
5
5
  resolveConfiguredPluginModulePaths,
6
6
  resolvePluginConfigSearchPaths as resolvePluginConfigSearchPathsFromShared,
7
7
  } from "@clinebot/shared/storage";
8
- import { loadAgentPluginsFromPaths } from "./plugin-loader";
8
+ import type { PluginLoadDiagnostics } from "./plugin-load-report";
9
+ import { loadAgentPluginsFromPathsWithDiagnostics } from "./plugin-loader";
9
10
  import { loadSandboxedPlugins } from "./plugin-sandbox";
10
11
 
11
12
  type AgentPlugin = NonNullable<AgentConfig["extensions"]>[number];
@@ -64,21 +65,26 @@ export interface ResolveAndLoadAgentPluginsOptions
64
65
 
65
66
  export async function resolveAndLoadAgentPlugins(
66
67
  options: ResolveAndLoadAgentPluginsOptions = {},
67
- ): Promise<{
68
- extensions: AgentPlugin[];
69
- shutdown?: () => Promise<void>;
70
- }> {
68
+ ): Promise<
69
+ {
70
+ extensions: AgentPlugin[];
71
+ shutdown?: () => Promise<void>;
72
+ } & PluginLoadDiagnostics
73
+ > {
71
74
  const paths = resolveAgentPluginPaths(options);
72
75
  if (paths.length === 0) {
73
- return { extensions: [] };
76
+ return { extensions: [], failures: [], warnings: [] };
74
77
  }
75
78
 
76
79
  if (options.mode === "in_process") {
80
+ const report = await loadAgentPluginsFromPathsWithDiagnostics(paths, {
81
+ cwd: options.cwd,
82
+ exportName: options.exportName,
83
+ });
77
84
  return {
78
- extensions: await loadAgentPluginsFromPaths(paths, {
79
- cwd: options.cwd,
80
- exportName: options.exportName,
81
- }),
85
+ extensions: report.plugins,
86
+ failures: report.failures,
87
+ warnings: report.warnings,
82
88
  };
83
89
  }
84
90
 
@@ -93,5 +99,7 @@ export async function resolveAndLoadAgentPlugins(
93
99
  return {
94
100
  extensions: sandboxed.extensions ?? [],
95
101
  shutdown: sandboxed.shutdown,
102
+ failures: sandboxed.failures,
103
+ warnings: sandboxed.warnings,
96
104
  };
97
105
  }
@@ -0,0 +1,20 @@
1
+ export interface PluginInitializationFailure {
2
+ pluginPath: string;
3
+ pluginName?: string;
4
+ phase: "load" | "setup";
5
+ message: string;
6
+ stack?: string;
7
+ }
8
+
9
+ export interface PluginInitializationWarning {
10
+ type: "duplicate_plugin_override";
11
+ pluginPath: string;
12
+ pluginName: string;
13
+ overriddenPluginPath: string;
14
+ message: string;
15
+ }
16
+
17
+ export interface PluginLoadDiagnostics {
18
+ failures: PluginInitializationFailure[];
19
+ warnings: PluginInitializationWarning[];
20
+ }
@@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
5
  import {
6
6
  loadAgentPluginFromPath,
7
7
  loadAgentPluginsFromPaths,
8
+ loadAgentPluginsFromPathsWithDiagnostics,
8
9
  } from "./plugin-loader";
9
10
 
10
11
  describe("plugin-loader", () => {
@@ -158,6 +159,17 @@ describe("plugin-loader", () => {
158
159
  "export default { name: 'invalid-plugin' };",
159
160
  "utf8",
160
161
  );
162
+
163
+ await writeFile(
164
+ join(dir, "duplicate-one.mjs"),
165
+ "export default { name: 'duplicate-plugin', manifest: { capabilities: ['tools'] } };",
166
+ "utf8",
167
+ );
168
+ await writeFile(
169
+ join(dir, "duplicate-two.mjs"),
170
+ "export default { name: 'duplicate-plugin', manifest: { capabilities: ['commands'] } };",
171
+ "utf8",
172
+ );
161
173
  });
162
174
 
163
175
  afterAll(async () => {
@@ -244,4 +256,37 @@ describe("plugin-loader", () => {
244
256
  loadAgentPluginFromPath(join(dir, "invalid-plugin.mjs")),
245
257
  ).rejects.toThrow(/missing required "manifest"/i);
246
258
  });
259
+
260
+ it("continues loading valid plugins when one plugin fails", async () => {
261
+ const report = await loadAgentPluginsFromPathsWithDiagnostics([
262
+ join(dir, "plugin-a.mjs"),
263
+ join(dir, "invalid-plugin.mjs"),
264
+ join(dir, "plugin-b.mjs"),
265
+ ]);
266
+
267
+ expect(report.plugins.map((plugin) => plugin.name)).toEqual([
268
+ "plugin-a",
269
+ "plugin-b",
270
+ ]);
271
+ expect(report.failures).toHaveLength(1);
272
+ expect(report.failures[0]?.pluginPath).toBe(
273
+ join(dir, "invalid-plugin.mjs"),
274
+ );
275
+ expect(report.warnings).toEqual([]);
276
+ });
277
+
278
+ it("keeps the later duplicate plugin and reports the override", async () => {
279
+ const report = await loadAgentPluginsFromPathsWithDiagnostics([
280
+ join(dir, "duplicate-one.mjs"),
281
+ join(dir, "duplicate-two.mjs"),
282
+ ]);
283
+
284
+ expect(report.plugins).toHaveLength(1);
285
+ expect(report.plugins[0]?.name).toBe("duplicate-plugin");
286
+ expect(report.plugins[0]?.manifest.capabilities).toEqual(["commands"]);
287
+ expect(report.warnings).toHaveLength(1);
288
+ expect(report.warnings[0]?.overriddenPluginPath).toBe(
289
+ join(dir, "duplicate-one.mjs"),
290
+ );
291
+ });
247
292
  });
@@ -1,5 +1,9 @@
1
1
  import { resolve } from "node:path";
2
2
  import type { AgentConfig } from "@clinebot/shared";
3
+ import type {
4
+ PluginInitializationFailure,
5
+ PluginInitializationWarning,
6
+ } from "./plugin-load-report";
3
7
  import { importPluginModule } from "./plugin-module-import";
4
8
 
5
9
  type AgentPlugin = NonNullable<AgentConfig["extensions"]>[number];
@@ -98,9 +102,59 @@ export async function loadAgentPluginsFromPaths(
98
102
  pluginPaths: string[],
99
103
  options: LoadAgentPluginFromPathOptions = {},
100
104
  ): Promise<AgentPlugin[]> {
101
- const loaded: AgentPlugin[] = [];
105
+ const report = await loadAgentPluginsFromPathsWithDiagnostics(
106
+ pluginPaths,
107
+ options,
108
+ );
109
+ return report.plugins;
110
+ }
111
+
112
+ export async function loadAgentPluginsFromPathsWithDiagnostics(
113
+ pluginPaths: string[],
114
+ options: LoadAgentPluginFromPathOptions = {},
115
+ ): Promise<{
116
+ plugins: AgentPlugin[];
117
+ failures: PluginInitializationFailure[];
118
+ warnings: PluginInitializationWarning[];
119
+ }> {
120
+ const failures: PluginInitializationFailure[] = [];
121
+ const warnings: PluginInitializationWarning[] = [];
122
+ const loadedByName = new Map<
123
+ string,
124
+ { plugin: AgentPlugin; pluginPath: string; order: number }
125
+ >();
126
+ let order = 0;
127
+
102
128
  for (const pluginPath of pluginPaths) {
103
- loaded.push(await loadAgentPluginFromPath(pluginPath, options));
129
+ try {
130
+ const plugin = await loadAgentPluginFromPath(pluginPath, options);
131
+ const existing = loadedByName.get(plugin.name);
132
+ if (existing) {
133
+ warnings.push({
134
+ type: "duplicate_plugin_override",
135
+ pluginName: plugin.name,
136
+ pluginPath,
137
+ overriddenPluginPath: existing.pluginPath,
138
+ message: `Plugin "${plugin.name}" from ${pluginPath} overrides ${existing.pluginPath}`,
139
+ });
140
+ }
141
+ loadedByName.set(plugin.name, { plugin, pluginPath, order: order++ });
142
+ } catch (error) {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ failures.push({
145
+ pluginPath,
146
+ phase: "load",
147
+ message,
148
+ stack: error instanceof Error ? error.stack : undefined,
149
+ });
150
+ }
104
151
  }
105
- return loaded;
152
+
153
+ return {
154
+ plugins: [...loadedByName.values()]
155
+ .sort((left, right) => left.order - right.order)
156
+ .map((entry) => entry.plugin),
157
+ failures,
158
+ warnings,
159
+ };
106
160
  }
@@ -84,6 +84,7 @@ interface ContributionDescriptor {
84
84
 
85
85
  interface PluginDescriptor {
86
86
  pluginId: string;
87
+ pluginPath: string;
87
88
  name: string;
88
89
  manifest: Record<string, unknown>;
89
90
  contributions: {
@@ -96,6 +97,28 @@ interface PluginDescriptor {
96
97
  };
97
98
  }
98
99
 
100
+ interface PluginInitializationFailure {
101
+ pluginPath: string;
102
+ pluginName?: string;
103
+ phase: "load" | "setup";
104
+ message: string;
105
+ stack?: string;
106
+ }
107
+
108
+ interface PluginInitializationWarning {
109
+ type: "duplicate_plugin_override";
110
+ pluginPath: string;
111
+ pluginName: string;
112
+ overriddenPluginPath: string;
113
+ message: string;
114
+ }
115
+
116
+ interface InitializeResult {
117
+ plugins: PluginDescriptor[];
118
+ failures: PluginInitializationFailure[];
119
+ warnings: PluginInitializationWarning[];
120
+ }
121
+
99
122
  interface PluginState {
100
123
  plugin: PluginModule;
101
124
  handlers: {
@@ -191,104 +214,153 @@ function getPlugin(pluginId: string): PluginState {
191
214
  async function initialize(args: {
192
215
  pluginPaths?: string[];
193
216
  exportName?: string;
194
- }): Promise<PluginDescriptor[]> {
217
+ }): Promise<InitializeResult> {
195
218
  pluginState.clear();
196
219
  pluginCounter = 0;
197
220
  contributionCounters.clear();
198
221
 
199
222
  const descriptors: PluginDescriptor[] = [];
223
+ const failures: PluginInitializationFailure[] = [];
224
+ const warnings: PluginInitializationWarning[] = [];
200
225
  const exportName = args.exportName || "plugin";
226
+ const pluginIndexByName = new Map<string, number>();
201
227
 
202
228
  for (const pluginPath of args.pluginPaths || []) {
203
- const moduleExports = await importPluginModule(pluginPath);
204
- const plugin = (moduleExports.default ??
205
- moduleExports[exportName]) as unknown;
206
- assertValidPluginModule(plugin, pluginPath);
207
-
208
- const pluginId = `plugin_${++pluginCounter}`;
209
- const contributions: PluginDescriptor["contributions"] = {
210
- tools: [],
211
- commands: [],
212
- shortcuts: [],
213
- flags: [],
214
- messageBuilders: [],
215
- providers: [],
216
- };
217
- const handlers: PluginState["handlers"] = {
218
- tools: new Map(),
219
- commands: new Map(),
220
- messageBuilders: new Map(),
221
- };
222
-
223
- const api: PluginApi = {
224
- registerTool: (tool) => {
225
- const id = makeId(pluginId, "tool");
226
- handlers.tools.set(id, tool.execute);
227
- contributions.tools.push({
228
- id,
229
- name: tool.name,
230
- description: tool.description,
231
- inputSchema: tool.inputSchema,
232
- timeoutMs: tool.timeoutMs,
233
- retryable: tool.retryable,
234
- });
235
- },
236
- registerCommand: (command) => {
237
- const id = makeId(pluginId, "command");
238
- if (typeof command.handler === "function") {
239
- handlers.commands.set(id, command.handler);
229
+ let plugin: PluginModule | undefined;
230
+ try {
231
+ const moduleExports = await importPluginModule(pluginPath);
232
+ plugin = (moduleExports.default ??
233
+ moduleExports[exportName]) as unknown as PluginModule;
234
+ assertValidPluginModule(plugin, pluginPath);
235
+
236
+ const pluginId = `plugin_${++pluginCounter}`;
237
+ const contributions: PluginDescriptor["contributions"] = {
238
+ tools: [],
239
+ commands: [],
240
+ shortcuts: [],
241
+ flags: [],
242
+ messageBuilders: [],
243
+ providers: [],
244
+ };
245
+ const handlers: PluginState["handlers"] = {
246
+ tools: new Map(),
247
+ commands: new Map(),
248
+ messageBuilders: new Map(),
249
+ };
250
+
251
+ const api: PluginApi = {
252
+ registerTool: (tool) => {
253
+ const id = makeId(pluginId, "tool");
254
+ handlers.tools.set(id, tool.execute);
255
+ contributions.tools.push({
256
+ id,
257
+ name: tool.name,
258
+ description: tool.description,
259
+ inputSchema: tool.inputSchema,
260
+ timeoutMs: tool.timeoutMs,
261
+ retryable: tool.retryable,
262
+ });
263
+ },
264
+ registerCommand: (command) => {
265
+ const id = makeId(pluginId, "command");
266
+ if (typeof command.handler === "function") {
267
+ handlers.commands.set(id, command.handler);
268
+ }
269
+ contributions.commands.push({
270
+ id,
271
+ name: command.name,
272
+ description: command.description,
273
+ });
274
+ },
275
+ registerShortcut: (shortcut) => {
276
+ contributions.shortcuts.push({
277
+ id: makeId(pluginId, "shortcut"),
278
+ name: shortcut.name,
279
+ value: shortcut.value,
280
+ description: shortcut.description,
281
+ });
282
+ },
283
+ registerFlag: (flag) => {
284
+ contributions.flags.push({
285
+ id: makeId(pluginId, "flag"),
286
+ name: flag.name,
287
+ description: flag.description,
288
+ defaultValue: flag.defaultValue,
289
+ });
290
+ },
291
+ registerMessageBuilder: (builder) => {
292
+ const id = makeId(pluginId, "builder");
293
+ handlers.messageBuilders.set(id, builder.build);
294
+ contributions.messageBuilders.push({ id, name: builder.name });
295
+ },
296
+ registerProvider: (provider) => {
297
+ contributions.providers.push({
298
+ id: makeId(pluginId, "provider"),
299
+ name: provider.name,
300
+ description: provider.description,
301
+ metadata: sanitizeObject(provider.metadata),
302
+ });
303
+ },
304
+ };
305
+
306
+ if (typeof plugin.setup === "function") {
307
+ try {
308
+ await plugin.setup(api);
309
+ } catch (error) {
310
+ failures.push({
311
+ pluginPath,
312
+ pluginName: plugin.name,
313
+ phase: "setup",
314
+ message: error instanceof Error ? error.message : String(error),
315
+ stack: error instanceof Error ? error.stack : undefined,
316
+ });
317
+ continue;
318
+ }
319
+ }
320
+
321
+ const previousIndex = pluginIndexByName.get(plugin.name);
322
+ if (previousIndex !== undefined) {
323
+ const previous = descriptors[previousIndex];
324
+ if (!previous) {
325
+ pluginIndexByName.delete(plugin.name);
326
+ } else {
327
+ warnings.push({
328
+ type: "duplicate_plugin_override",
329
+ pluginName: plugin.name,
330
+ pluginPath,
331
+ overriddenPluginPath: previous.pluginPath,
332
+ message: `Plugin "${plugin.name}" from ${pluginPath} overrides ${previous.pluginPath}`,
333
+ });
334
+ pluginState.delete(previous.pluginId);
335
+ descriptors.splice(previousIndex, 1);
336
+ pluginIndexByName.clear();
337
+ for (const [index, descriptor] of descriptors.entries()) {
338
+ pluginIndexByName.set(descriptor.name, index);
339
+ }
240
340
  }
241
- contributions.commands.push({
242
- id,
243
- name: command.name,
244
- description: command.description,
245
- });
246
- },
247
- registerShortcut: (shortcut) => {
248
- contributions.shortcuts.push({
249
- id: makeId(pluginId, "shortcut"),
250
- name: shortcut.name,
251
- value: shortcut.value,
252
- description: shortcut.description,
253
- });
254
- },
255
- registerFlag: (flag) => {
256
- contributions.flags.push({
257
- id: makeId(pluginId, "flag"),
258
- name: flag.name,
259
- description: flag.description,
260
- defaultValue: flag.defaultValue,
261
- });
262
- },
263
- registerMessageBuilder: (builder) => {
264
- const id = makeId(pluginId, "builder");
265
- handlers.messageBuilders.set(id, builder.build);
266
- contributions.messageBuilders.push({ id, name: builder.name });
267
- },
268
- registerProvider: (provider) => {
269
- contributions.providers.push({
270
- id: makeId(pluginId, "provider"),
271
- name: provider.name,
272
- description: provider.description,
273
- metadata: sanitizeObject(provider.metadata),
274
- });
275
- },
276
- };
277
-
278
- if (typeof plugin.setup === "function") {
279
- await plugin.setup(api);
341
+ }
342
+
343
+ pluginState.set(pluginId, { plugin, handlers });
344
+ pluginIndexByName.set(plugin.name, descriptors.length);
345
+ descriptors.push({
346
+ pluginId,
347
+ pluginPath,
348
+ name: plugin.name,
349
+ manifest: plugin.manifest,
350
+ contributions,
351
+ });
352
+ } catch (error) {
353
+ failures.push({
354
+ pluginPath,
355
+ pluginName: plugin?.name,
356
+ phase: "load",
357
+ message: error instanceof Error ? error.message : String(error),
358
+ stack: error instanceof Error ? error.stack : undefined,
359
+ });
280
360
  }
281
-
282
- pluginState.set(pluginId, { plugin, handlers });
283
- descriptors.push({
284
- pluginId,
285
- name: plugin.name,
286
- manifest: plugin.manifest,
287
- contributions,
288
- });
289
361
  }
290
362
 
291
- return descriptors;
363
+ return { plugins: descriptors, failures, warnings };
292
364
  }
293
365
 
294
366
  async function invokeHook(args: {
@@ -184,6 +184,31 @@ describe("plugin-sandbox", () => {
184
184
  "utf8",
185
185
  );
186
186
 
187
+ await writeFile(
188
+ join(dir, "plugin-broken-setup.mjs"),
189
+ [
190
+ "export default {",
191
+ " name: 'sandbox-broken-setup',",
192
+ " manifest: { capabilities: ['tools'] },",
193
+ " async setup() {",
194
+ " throw new Error('broken setup');",
195
+ " },",
196
+ "};",
197
+ ].join("\n"),
198
+ "utf8",
199
+ );
200
+
201
+ await writeFile(
202
+ join(dir, "plugin-duplicate-a.mjs"),
203
+ "export default { name: 'sandbox-duplicate', manifest: { capabilities: ['tools'] } };",
204
+ "utf8",
205
+ );
206
+ await writeFile(
207
+ join(dir, "plugin-duplicate-b.mjs"),
208
+ "export default { name: 'sandbox-duplicate', manifest: { capabilities: ['commands'] } };",
209
+ "utf8",
210
+ );
211
+
187
212
  sharedSandbox = await loadSandboxedPlugins({
188
213
  pluginPaths: [
189
214
  join(dir, "plugin.mjs"),
@@ -321,6 +346,51 @@ describe("plugin-sandbox", () => {
321
346
  expect(result).toEqual({ echoed: "ok" });
322
347
  });
323
348
 
349
+ it("continues loading remaining sandbox plugins when one setup fails", async () => {
350
+ const sandboxed = await loadSandboxedPlugins({
351
+ pluginPaths: [
352
+ join(dir, "plugin.mjs"),
353
+ join(dir, "plugin-broken-setup.mjs"),
354
+ join(dir, "plugin-events.mjs"),
355
+ ],
356
+ });
357
+
358
+ try {
359
+ expect(sandboxed.extensions?.map((extension) => extension.name)).toEqual([
360
+ "sandbox-test",
361
+ "sandbox-events",
362
+ ]);
363
+ expect(sandboxed.failures).toHaveLength(1);
364
+ expect(sandboxed.failures[0]?.pluginName).toBe("sandbox-broken-setup");
365
+ expect(sandboxed.failures[0]?.phase).toBe("setup");
366
+ } finally {
367
+ await sandboxed.shutdown();
368
+ }
369
+ });
370
+
371
+ it("keeps the later duplicate sandbox plugin and reports the override", async () => {
372
+ const sandboxed = await loadSandboxedPlugins({
373
+ pluginPaths: [
374
+ join(dir, "plugin-duplicate-a.mjs"),
375
+ join(dir, "plugin-duplicate-b.mjs"),
376
+ ],
377
+ });
378
+
379
+ try {
380
+ expect(sandboxed.extensions).toHaveLength(1);
381
+ expect(sandboxed.extensions?.[0]?.name).toBe("sandbox-duplicate");
382
+ expect(sandboxed.extensions?.[0]?.manifest.capabilities).toEqual([
383
+ "commands",
384
+ ]);
385
+ expect(sandboxed.warnings).toHaveLength(1);
386
+ expect(sandboxed.warnings[0]?.overriddenPluginPath).toBe(
387
+ join(dir, "plugin-duplicate-a.mjs"),
388
+ );
389
+ } finally {
390
+ await sandboxed.shutdown();
391
+ }
392
+ });
393
+
324
394
  it("resolves plugin-local dependencies in the sandbox process", async () => {
325
395
  expect(sharedExtensions.get("sandbox-local-dep")?.name).toBe(
326
396
  "sandbox-local-dep",